From bd52bf3538013b55c371e5aa63ef197718e6cc4c Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 03:22:18 -0400 Subject: [PATCH 01/22] feat(moduletools): make xoops/moduletools a standalone PHP 8.4 library Turn the Core-bundled 1.4.0 tree into a self-contained Composer package that installs, tests and analyses on its own while still running inside xoops_lib/vendor on a XOOPS 2.8 site. Package - Ship resources/ (language catalog, Cloner font); scripts and tests resolve the package-local vendor/autoload.php, falling back to Core's. - Require PHP ^8.4; widen xoops/xmf to ^1.3 (every API used exists in 1.3.1). Drop the hardcoded version; sites pin it on the path repo. - Quality gate: phpcs (PSR-12), PHPStan level 6 with stubs generated from a real XOOPS 2.8 core, Rector (PHP 8.4 + xoops/rector-xoops) applied, package-local API/matrix/layer/global checks, PHPUnit. CI on 8.4/8.5. - Gate artifacts live in resources/gate (export-ignored); site-specific audits are no longer required. Reconcile the 1.5.0 consolidated cut - DirectoryChecker::handleRequest() with create/chmod action forms, fail-closed CSRF check, catalog fallback; FileChecker constant names; Blocksadmin _MI_* title-token handling; Cloner/DirectoryChecker tests. - Restore Admin\Table and Common\Highlighter from the path-repo build. Fixes - Xmf\Module\Helper::getDirname() does not exist; use dirname() at every call site (Blocksadmin, Cloner, Confirm, LetterChoice, Testdata*). - DynamicObject::setErrors() called a non-existent setError(). - Blocksadmin: undefined $newid on failed store(); render() echoed twice. - Cloner: sprintf on placeholder-less constants; partial clones now throw. - Db::cloneRecord() passed a second argument to fetchArray(). - Export: fputcsv escape argument (PHP 8.4 deprecation). - VersionChecks: by-reference assignment from a call, dead fallbacks. - Strict-type mismatches in Confirm, Output, ObjectFormBuilder. - LetterChoice: setCaching(CACHING_OFF) instead of a Smarty property write. - UpdateChecker accepts a repository and honours the github_repo manifest entry; ModuleFeedback and ServerStats resolve consumer constants first. --- .coderabbit.yaml | 47 + .editorconfig | 26 + .gitattributes | 39 + .github/ISSUE_TEMPLATE/bug_report.yml | 38 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 25 + .github/PULL_REQUEST_TEMPLATE.md | 14 + .github/dependabot.yml | 13 + .github/workflows/ci.yml | 42 + .github/workflows/code-coverage.yml | 38 + .github/workflows/dependency-review.yml | 28 + .gitignore | 24 + .scrutinizer.yml | 20 + CHANGELOG.md | 46 + CODEOWNERS | 1 + CONTRIBUTING.md | 34 + LICENSE | 348 +- README.md | 37 +- SECURITY.md | 18 + SUPPORT.md | 14 + composer.json | 106 + index.php | 3 + phpcs.xml | 26 + phpstan-baseline.neon | 937 + phpstan-bootstrap.php | 13 + phpstan.neon | 21 + phpunit.xml.dist | 18 + rector.php | 18 + resources/fonts/VeraBd.ttf | Bin 0 -> 58716 bytes resources/gate/api-surface-allowlist.json | 74 + resources/gate/api-surface-baseline.json | 8021 +++++++ resources/gate/api-surface.json | 8621 +++++++ resources/gate/destination-matrix.json | 19917 ++++++++++++++++ resources/gate/destination-matrix.md | 475 + resources/gate/promotion-backlog.md | 328 + resources/language/english/common.php | 232 + resources/migration/class-reuse-decisions.php | 33 + resources/migration/destination-decisions.php | 302 + scripts/check-api-compat.php | 67 + scripts/check-destination-matrix.php | 65 + scripts/check-global-accessors.php | 63 + scripts/check-layers.php | 75 + scripts/generate-api-surface.php | 214 + scripts/generate-destination-matrix.php | 124 + scripts/lint-php.php | 38 + sonar-project.properties | 12 + src/Admin/Export.php | 64 + src/Admin/ObjectColumn.php | 19 + src/Admin/ObjectController.php | 151 + src/Admin/ObjectRow.php | 16 + src/Admin/ObjectTable.php | 162 + src/Admin/SingleView.php | 44 + src/Admin/Table.php | 10 + src/Admin/TreeTable.php | 10 + src/Admin/Utility.php | 71 + src/Bootstrap.php | 91 + src/Common/Blocksadmin.php | 745 + src/Common/Breadcrumb.php | 96 + src/Common/Cloner.php | 199 + src/Common/Configurator.php | 197 + src/Common/Confirm.php | 120 + src/Common/Db.php | 213 + src/Common/DbInterface.php | 74 + src/Common/DirectoryChecker.php | 279 + src/Common/FileChecker.php | 221 + src/Common/FilesManagement.php | 270 + src/Common/Highlighter.php | 28 + src/Common/ImageResizerInterface.php | 21 + src/Common/LetterChoice.php | 181 + src/Common/Media/MediaUploadRequest.php | 38 + src/Common/Migrate.php | 108 + src/Common/ModuleConfig.php | 70 + src/Common/ModuleFeedback.php | 152 + src/Common/ModuleStats.php | 37 + src/Common/ObjectTree.php | 90 + src/Common/Output.php | 126 + src/Common/PaginationState.php | 122 + src/Common/PaginationStateInterface.php | 66 + src/Common/Paginator.php | 287 + src/Common/ResizeRequest.php | 45 + src/Common/ResizeResult.php | 34 + src/Common/Resizer.php | 170 + src/Common/ServerStats.php | 115 + src/Common/SocialBookmarks.php | 85 + src/Common/StandardConfig.php | 68 + src/Common/SysUtility.php | 283 + src/Common/TestdataButtons.php | 253 + src/Common/TestdataSample.php | 191 + src/Common/Text.php | 137 + src/Common/TextInterface.php | 45 + src/Common/UpdateChecker.php | 128 + src/Common/VersionChecks.php | 96 + src/Constants.php | 14 + src/Form/ObjectFormBuilder.php | 115 + .../Confirmation/ConfirmationModel.php | 19 + .../Confirmation/ConfirmationResolver.php | 44 + .../Installation/InstallationPlan.php | 22 + src/Internal/PackageLanguage.php | 44 + .../Presentation/SortControlBuilder.php | 30 + .../Presentation/SortControlResult.php | 18 + .../Tools/ConsumerBridgeGenerator.php | 684 + src/Internal/Update/VersionUpdatePolicy.php | 22 + src/Module/ConsumerRuntime.php | 87 + src/Module/Dependency.php | 121 + src/Module/Installer.php | 178 + src/Module/ModuleContext.php | 146 + src/Module/NamespaceAutoloader.php | 183 + src/Object/DynamicObject.php | 239 + src/Object/SeoObject.php | 26 + src/Permission/ItemPermission.php | 96 + src/Permission/PermissionGatewayInterface.php | 21 + src/Permission/XoopsPermissionGateway.php | 52 + src/Persistence/PersistableHandler.php | 154 + src/Persistence/PersistableHandler2.php | 17 + src/Utility.php | 14 + src/legacy_aliases.php | 29 + stubs/xoops-constants.php | 77 + stubs/xoops-core.stub | 8527 +++++++ tests/Admin/UtilityTest.php | 26 + tests/ApiSurfaceTest.php | 39 + tests/BootstrapTest.php | 96 + tests/Common/ClonerTest.php | 73 + tests/Common/DirectoryCheckerTest.php | 70 + tests/Common/ModuleConfigTest.php | 21 + tests/Common/ModuleStatsTest.php | 28 + tests/Common/ServerStatsTest.php | 65 + tests/ExtcalConsumerTest.php | 53 + tests/Internal/PackageLanguageTest.php | 23 + tests/Internal/SemanticServicesTest.php | 60 + tests/Migration/FilesystemDelegationTest.php | 55 + tests/Migration/MigrateDelegationTest.php | 18 + .../Migration/ModuleContextDelegationTest.php | 23 + tests/Migration/PaginationNonParityTest.php | 35 + tests/Module/NamespaceAutoloaderTest.php | 34 + tests/Permission/ItemPermissionTest.php | 83 + tests/QuotesConsumerTest.php | 54 + tests/SmartModulesConsumerTest.php | 146 + tests/Tools/ConsumerBridgeGeneratorTest.php | 111 + tests/bootstrap.php | 10 + tests/fixtures/core-stubs.php | 29 + 140 files changed, 58482 insertions(+), 337 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/code-coverage.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .gitignore create mode 100644 .scrutinizer.yml create mode 100644 CHANGELOG.md create mode 100644 CODEOWNERS create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 composer.json create mode 100644 index.php create mode 100644 phpcs.xml create mode 100644 phpstan-baseline.neon create mode 100644 phpstan-bootstrap.php create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 rector.php create mode 100644 resources/fonts/VeraBd.ttf create mode 100644 resources/gate/api-surface-allowlist.json create mode 100644 resources/gate/api-surface-baseline.json create mode 100644 resources/gate/api-surface.json create mode 100644 resources/gate/destination-matrix.json create mode 100644 resources/gate/destination-matrix.md create mode 100644 resources/gate/promotion-backlog.md create mode 100644 resources/language/english/common.php create mode 100644 resources/migration/class-reuse-decisions.php create mode 100644 resources/migration/destination-decisions.php create mode 100644 scripts/check-api-compat.php create mode 100644 scripts/check-destination-matrix.php create mode 100644 scripts/check-global-accessors.php create mode 100644 scripts/check-layers.php create mode 100644 scripts/generate-api-surface.php create mode 100644 scripts/generate-destination-matrix.php create mode 100644 scripts/lint-php.php create mode 100644 sonar-project.properties create mode 100644 src/Admin/Export.php create mode 100644 src/Admin/ObjectColumn.php create mode 100644 src/Admin/ObjectController.php create mode 100644 src/Admin/ObjectRow.php create mode 100644 src/Admin/ObjectTable.php create mode 100644 src/Admin/SingleView.php create mode 100644 src/Admin/Table.php create mode 100644 src/Admin/TreeTable.php create mode 100644 src/Admin/Utility.php create mode 100644 src/Bootstrap.php create mode 100644 src/Common/Blocksadmin.php create mode 100644 src/Common/Breadcrumb.php create mode 100644 src/Common/Cloner.php create mode 100644 src/Common/Configurator.php create mode 100644 src/Common/Confirm.php create mode 100644 src/Common/Db.php create mode 100644 src/Common/DbInterface.php create mode 100644 src/Common/DirectoryChecker.php create mode 100644 src/Common/FileChecker.php create mode 100644 src/Common/FilesManagement.php create mode 100644 src/Common/Highlighter.php create mode 100644 src/Common/ImageResizerInterface.php create mode 100644 src/Common/LetterChoice.php create mode 100644 src/Common/Media/MediaUploadRequest.php create mode 100644 src/Common/Migrate.php create mode 100644 src/Common/ModuleConfig.php create mode 100644 src/Common/ModuleFeedback.php create mode 100644 src/Common/ModuleStats.php create mode 100644 src/Common/ObjectTree.php create mode 100644 src/Common/Output.php create mode 100644 src/Common/PaginationState.php create mode 100644 src/Common/PaginationStateInterface.php create mode 100644 src/Common/Paginator.php create mode 100644 src/Common/ResizeRequest.php create mode 100644 src/Common/ResizeResult.php create mode 100644 src/Common/Resizer.php create mode 100644 src/Common/ServerStats.php create mode 100644 src/Common/SocialBookmarks.php create mode 100644 src/Common/StandardConfig.php create mode 100644 src/Common/SysUtility.php create mode 100644 src/Common/TestdataButtons.php create mode 100644 src/Common/TestdataSample.php create mode 100644 src/Common/Text.php create mode 100644 src/Common/TextInterface.php create mode 100644 src/Common/UpdateChecker.php create mode 100644 src/Common/VersionChecks.php create mode 100644 src/Constants.php create mode 100644 src/Form/ObjectFormBuilder.php create mode 100644 src/Internal/Confirmation/ConfirmationModel.php create mode 100644 src/Internal/Confirmation/ConfirmationResolver.php create mode 100644 src/Internal/Installation/InstallationPlan.php create mode 100644 src/Internal/PackageLanguage.php create mode 100644 src/Internal/Presentation/SortControlBuilder.php create mode 100644 src/Internal/Presentation/SortControlResult.php create mode 100644 src/Internal/Tools/ConsumerBridgeGenerator.php create mode 100644 src/Internal/Update/VersionUpdatePolicy.php create mode 100644 src/Module/ConsumerRuntime.php create mode 100644 src/Module/Dependency.php create mode 100644 src/Module/Installer.php create mode 100644 src/Module/ModuleContext.php create mode 100644 src/Module/NamespaceAutoloader.php create mode 100644 src/Object/DynamicObject.php create mode 100644 src/Object/SeoObject.php create mode 100644 src/Permission/ItemPermission.php create mode 100644 src/Permission/PermissionGatewayInterface.php create mode 100644 src/Permission/XoopsPermissionGateway.php create mode 100644 src/Persistence/PersistableHandler.php create mode 100644 src/Persistence/PersistableHandler2.php create mode 100644 src/Utility.php create mode 100644 src/legacy_aliases.php create mode 100644 stubs/xoops-constants.php create mode 100644 stubs/xoops-core.stub create mode 100644 tests/Admin/UtilityTest.php create mode 100644 tests/ApiSurfaceTest.php create mode 100644 tests/BootstrapTest.php create mode 100644 tests/Common/ClonerTest.php create mode 100644 tests/Common/DirectoryCheckerTest.php create mode 100644 tests/Common/ModuleConfigTest.php create mode 100644 tests/Common/ModuleStatsTest.php create mode 100644 tests/Common/ServerStatsTest.php create mode 100644 tests/ExtcalConsumerTest.php create mode 100644 tests/Internal/PackageLanguageTest.php create mode 100644 tests/Internal/SemanticServicesTest.php create mode 100644 tests/Migration/FilesystemDelegationTest.php create mode 100644 tests/Migration/MigrateDelegationTest.php create mode 100644 tests/Migration/ModuleContextDelegationTest.php create mode 100644 tests/Migration/PaginationNonParityTest.php create mode 100644 tests/Module/NamespaceAutoloaderTest.php create mode 100644 tests/Permission/ItemPermissionTest.php create mode 100644 tests/QuotesConsumerTest.php create mode 100644 tests/SmartModulesConsumerTest.php create mode 100644 tests/Tools/ConsumerBridgeGeneratorTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/fixtures/core-stubs.php diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..6102824 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,47 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +early_access: false +tone_instructions: >- + Expert PHP code reviewer. Focus on type safety, PSR-12, PHP 8.4+ + compatibility, and security. This is a utility library requiring PHP 8.4+, + so modern language features are fine when they aid clarity within range. + +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + tools: + phpcs: + enabled: true + phpstan: + enabled: true + gitleaks: + enabled: true + markdownlint: + enabled: true + yamllint: + enabled: true + github-checks: + enabled: true + timeout_ms: 90000 + path_instructions: + - path: "src/**/*.php" + instructions: >- + Review for PHP 8.4+ compatibility, type safety, and PSR-12 code style. + Modern PHP features are allowed when they improve clarity and remain + within the supported version range. Flag any hardcoded 'UTF-8' strings + that should use the ENCODING class constant. + - path: "tests/**/*.php" + instructions: >- + Review test code for proper assertions, test isolation, and edge + case coverage. Tests must remain compatible with this repository's + supported PHPUnit setup. + +chat: + auto_reply: true diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b153486 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.json] +indent_style = space +indent_size = 2 + +[*.xml] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dab2007 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,39 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Ensure LF line endings for specific file types +*.php text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.xml text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.dist text eol=lf + +# Export ignores (not included in Composer dist packages) +/_archive export-ignore +/tests export-ignore +/stubs export-ignore +/docs/internal export-ignore +.github/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.editorconfig export-ignore +.scrutinizer.yml export-ignore +phpcs.xml export-ignore +phpunit.xml.dist export-ignore +phpstan.neon export-ignore +phpstan-baseline.neon export-ignore +qodana.yaml export-ignore +renovate.json export-ignore +.coderabbit.yaml export-ignore +sonar-project.properties export-ignore +CHANGELOG.md export-ignore +CLAUDE.md export-ignore +/scripts export-ignore +/resources/migration export-ignore +phpstan-bootstrap.php export-ignore +rector.php export-ignore + +/resources/gate export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..969684b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,38 @@ +name: Bug report +description: Report a defect or regression in XOOPS ModuleTools. +title: "[Bug]: " +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: Summary + description: Describe the bug and the expected behavior. + validations: + required: true + - type: input + id: php_version + attributes: + label: PHP version + placeholder: "8.4.0 / 8.5.0 / 8.6.0" + validations: + required: true + - type: input + id: xoops_version + attributes: + label: XOOPS version + placeholder: "2.7.x / 2.8.x / custom" + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Include a minimal code sample or exact steps. + render: php + validations: + required: true + - type: textarea + id: logs + attributes: + label: Errors or logs + description: Paste stack traces, warnings, or screenshots if available. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..cf6c684 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/xoops/xoops-helpers/security/policy + about: Report security issues privately through the repository security policy. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..691861a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Propose a new helper, adapter, or integration. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem to solve + description: Describe the gap or developer pain point. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the API or behavior you want. + validations: + required: true + - type: textarea + id: compatibility + attributes: + label: Compatibility notes + description: Note any XOOPS, PHP, or BC concerns. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..540852c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Summary + +- Explain what changed. +- Link any related issue or discussion. + +## Validation + +- [ ] `composer validate --strict` +- [ ] `composer test` +- [ ] `composer analyse` + +## Notes + +- Mention any compatibility, migration, or follow-up work. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..138a95b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aa2597a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + qa: + name: QA (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, gd + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Validate composer.json + run: composer validate --strict + + - name: Quality gate + run: composer qa diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 0000000..8d60461 --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,38 @@ +name: Code Coverage + +on: + push: + branches: [main, master] + +permissions: + contents: read + +jobs: + coverage: + name: Coverage Report + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, intl, zip + coverage: xdebug + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Generate coverage + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload to Codecov + uses: codecov/codecov-action@v7 + with: + files: coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..4871b66 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,28 @@ +name: Dependency Review + +# NOTE: actions/dependency-review-action requires the repository's Dependency +# Graph to be enabled (Settings → Code security → Dependency graph). Until that +# is on, the action fails with "Dependency review is not supported on this +# repository". The action step is marked continue-on-error so a disabled graph +# cannot block (or red-flag) PRs: the step may fail while the job still concludes +# successfully. Once Dependency graph is enabled, remove continue-on-error from +# the step to make this a hard gate again. + +on: + pull_request: + branches: [main, master] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Dependency review + uses: actions/dependency-review-action@v5 + continue-on-error: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..993c9c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Dependencies / build artifacts +/vendor/ +/.build/ +/composer.lock +coverage.clover +coverage.xml + +# Local config overrides (only .dist files are tracked) +/phpunit.xml +/.phpcs.xml +/.phpunit.result.cache +/.phpunit.cache/ + +# Editor / tooling noise +*.bak +*~ +\#* +.idea/ +.claude/ +CLAUDE.md +/tasks/ + +# Internal project history (audits, strategy notes) — never published +/docs/internal/ diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..5d771d2 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,20 @@ +build: + environment: + php: 8.4 + nodes: + analysis: + tests: + override: + - php-scrutinizer-run + +filter: + excluded_paths: + - '_archive/*' + - 'tests/*' + - 'vendor/*' + - 'docs/*' + dependency_paths: + - 'stubs/' + +tools: + php_analyzer: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3ca65f9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog and this project follows Semantic Versioning where practical. + +## [Unreleased] + +### Changed +- **Requires PHP 8.4+** (was 8.2). Rector PHP 8.4 + `xoops/rector-xoops` sets applied across `src/` + (readonly value objects, constructor promotion, first-class callables, typed class constants). +- `xoops/xmf` constraint widened to `^1.3` — every XMF API the library calls exists in 1.3.1; + XOOPS 2.8 sites still receive their bundled 1.5. +- `Common\LetterChoice` disables Smarty caching via `setCaching(\Smarty\Smarty::CACHING_OFF)` + instead of writing the `$caching` property (Smarty 5). +- `Common\UpdateChecker::checkVerModule()` takes an optional `$repository` ("owner/repo") and + honours a `github_repo` module-manifest entry before falling back to `XoopsModules25x/{dirname}`. +- `Common\ModuleFeedback` labels resolve `_CO__*` consumer constants before the package + catalog; `Common\ServerStats` loads the package catalog via `PackageLanguage` instead of the + mTools module. +- `Common\Cloner::clone()` now throws `RuntimeException` on any unreadable/uncopyable entry + instead of silently returning a partial clone. + +### Fixed +- `Xmf\Module\Helper::getDirname()` does not exist; every call site now uses `dirname()` + (Blocksadmin, Cloner, Confirm, LetterChoice, TestdataButtons, TestdataSample). +- `Object\DynamicObject::setErrors()` called a non-existent `setError()`. +- `Common\Blocksadmin`: `$newid` was undefined when `store()` failed; `render()` output was echoed twice. +- `Common\Cloner`: `sprintf()` on language constants that carry no placeholder. +- `Common\Db::cloneRecord()` passed a second argument to `fetchArray()`. +- `Admin\Export` passes `escape: ''` to `fputcsv()` (PHP 8.4 deprecation). +- `Common\VersionChecks`: by-reference assignment from a function call, dead constant fallbacks. +- Strict-type mismatches in `Confirm`, `Output` and `ObjectFormBuilder` form-element constructors. + +### Added +- `Admin\Table` and `Common\Highlighter` restored from the 1.4.0 path-repository build. +- Standalone package tooling: `tests/bootstrap.php` and the package scripts resolve the local + `vendor/autoload.php` (falling back to Core's `xoops_lib/vendor/`), `composer qa` gate + (PHPCS, PHPStan level 6 with generated XOOPS core stubs, Rector dry-run, package checks, PHPUnit), + CI on PHP 8.4/8.5. +- `resources/language/english/common.php` shipped (required by `PackageLanguage`). + +## [0.0.1] — 2026-07-22 + +First internal pre-release. + diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..6f2256e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @MAMBAX7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0438f10 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +## Scope + +XOOPS ModuleTools aims to stay small, dependency-light, and compatible with PHP 8.4 through 8.5. New helpers should be broadly reusable and justified by repeated XOOPS development needs. + +## Development workflow + +1. Create a feature branch from `master`. +2. Keep changes focused on one bugfix or feature. +3. Add or update tests for every behavior change. +4. Run the local checks before opening a pull request. + +## Local checks + +```bash +composer validate --strict +composer test +composer analyse +``` + +## Coding expectations + +- Use `declare(strict_types=1)` in PHP files. +- Prefer explicit types and small, composable methods. +- Keep pure utilities framework-agnostic when possible. +- Avoid breaking public APIs without documenting the change in `CHANGELOG.md`. +- Add integration tests for XOOPS- or Smarty-specific behavior. + +## Pull requests + +- Explain the problem, the approach, and any compatibility impact. +- Link related issues when available. +- Call out BC breaks explicitly. diff --git a/LICENSE b/LICENSE index d159169..1956490 100644 --- a/LICENSE +++ b/LICENSE @@ -1,339 +1,15 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 +XOOPS ModuleTools +Copyright (C) 2026 XOOPS Development Team - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. - Preamble +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md index 56e5569..a47990d 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ -# moduletools \ No newline at end of file +# XOOPS ModuleTools + +ModuleTools is the XOOPS Core-bundled successor to the mTools helper-host module. It +provides reusable services under `Xoops\ModuleTools\` without requiring an +installed or active module. + +The package registers lazy aliases for the stable `XoopsModules\Mtools\*` API, +allowing existing modules to run on XOOPS 2.8 without an mTools database row. +The separately distributed mTools module remains the provider for XOOPS 2.7.x. + +Experimental `mTools\Lab\*`, module administration pages, theme assets, and the +legacy `mtools_setup` table are intentionally not part of this library. + +## Documentation + +- [Getting started](docs/getting-started.md) — using the library from a module +- [Migrating a module](docs/migrating-a-module.md) — replacing copied `Common` classes / the mTools module +- [Compatibility policy](docs/compatibility-policy.md) — what stays, for how long + +## Requirements + +- PHP 8.4+, XOOPS 2.8.0+ (`xoops/xmf` ^1.3, `xoops/helpers` ^1.0). Smarty is Core's; the + library ships no templates and only drives `XoopsTpl` from PHP. + +## Development + +```bash +composer install +composer qa # phpcs → phpstan (level 6, generated XOOPS stubs) → rector dry-run → package checks → phpunit +composer test # package checks + phpunit only +``` + +The consumer-module tests (`*ConsumerTest`) and the XMF 1.5 pagination parity test skip +automatically unless the package sits inside a XOOPS tree (`xoops_lib/vendor/xoops/moduletools`). +`stubs/xoops-core.stub` is generated from a real XOOPS 2.8 core so PHPStan can analyse the +library standalone; regenerate it when the core API the library touches changes. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e3bb9d4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest development branch and the latest published release line when feasible. + +## Reporting a vulnerability + +Please use GitHub's private vulnerability reporting for this repository. + +If private reporting is not available, contact the maintainers through a private channel and include: + +- a short description of the issue +- affected versions +- a minimal proof of concept +- any suggested mitigation + +Please do not open public issues for security vulnerabilities before a fix is available. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..f3f1aca --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,14 @@ +# Support + +## Getting help + +- Use GitHub Discussions if enabled for questions and design discussion. +- Use GitHub Issues for confirmed bugs or concrete feature requests. +- Use the security policy for vulnerability reports. + +## What to include + +- XOOPS version +- PHP version +- minimal reproduction code +- exact error output diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4eb6d42 --- /dev/null +++ b/composer.json @@ -0,0 +1,106 @@ +{ + "name": "xoops/moduletools", + "description": "Core-bundled reusable services and compatibility module helpers for XOOPS 2.8.0+ modules", + "keywords": [ + "xoops", + "modules", + "framework", + "utilities" + ], + "type": "library", + "license": "GPL-2.0-or-later", + "homepage": "https://xoops.org", + "require": { + "php": "^8.4", + "symfony/yaml": "^6.0 || ^7.0", + "xoops/helpers": "^1.0", + "xoops/xmf": "^1.3" + }, + "autoload": { + "psr-4": { + "Xoops\\ModuleTools\\": "src/" + }, + "files": [ + "src/legacy_aliases.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Xoops\\ModuleTools\\Tests\\": "tests/" + } + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.0", + "smarty/smarty": "^5.0", + "squizlabs/php_codesniffer": "^3.10", + "xoops/rector-xoops": "^1.0@alpha" + }, + "minimum-stability": "alpha", + "prefer-stable": true, + "config": { + "sort-packages": true + }, + "scripts": { + "unit": "phpunit --configuration=phpunit.xml.dist", + "lint": "@php scripts/lint-php.php", + "cs": "phpcs --standard=phpcs.xml", + "cs:fix": "phpcbf --standard=phpcs.xml", + "analyse": "phpstan analyse --configuration=phpstan.neon --memory-limit=512M", + "rector": "rector process --dry-run", + "rector:fix": "rector process", + "api:generate": "@php scripts/generate-api-surface.php", + "api:check": "@php scripts/generate-api-surface.php --check", + "api:compat": "@php scripts/check-api-compat.php", + "api:baseline": "@php scripts/check-api-compat.php --accept-baseline", + "matrix:generate": "@php scripts/generate-destination-matrix.php", + "matrix:check": [ + "@php scripts/generate-destination-matrix.php --check", + "@php scripts/check-destination-matrix.php" + ], + "layers:check": "@php scripts/check-layers.php", + "globals:check": "@php scripts/check-global-accessors.php", + "test": [ + "@lint", + "@api:check", + "@api:compat", + "@matrix:check", + "@layers:check", + "@globals:check", + "@unit" + ], + "qa": [ + "@cs", + "@analyse", + "@rector", + "@test" + ], + "check": "@qa" + }, + "scripts-descriptions": { + "unit": "Run the ModuleTools PHPUnit suite", + "lint": "Parse-check all package PHP sources", + "cs": "Check coding style (PHP_CodeSniffer, PSR-12)", + "cs:fix": "Auto-fix coding style", + "analyse": "Run PHPStan against src/", + "rector": "Preview Rector refactorings (PHP 8.4 + xoops/rector-xoops sets)", + "rector:fix": "Apply Rector refactorings", + "api:generate": "Regenerate the static public API snapshot", + "api:check": "Fail when the public API snapshot is stale", + "api:compat": "Reject public API changes not named in the allowlist", + "api:baseline": "Explicitly accept the current public API as the compatibility baseline", + "matrix:generate": "Regenerate destination and promotion-backlog artifacts", + "matrix:check": "Validate destination coverage and lifecycle fields", + "layers:check": "Enforce Internal classification and output boundaries", + "globals:check": "Require every legacy global read/write to live in a designated accessor", + "test": "Package-local verification gate (lint, API, matrix, layers, globals, PHPUnit)", + "qa": "Full gate: style, PHPStan, Rector dry-run, then @test", + "check": "Alias for @qa" + }, + "support": { + "issues": "https://github.com/XOOPS/moduletools/issues", + "source": "https://github.com/XOOPS/moduletools" + } +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..6491106 --- /dev/null +++ b/index.php @@ -0,0 +1,3 @@ + + + XOOPS Coding Standards (PSR-12) + + src + + + + + + + 0 + + + 0 + 0 + + */Object/SeoObject.php + + + + */_archive/* + */docs/* + */tests/* + */vendor/* + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..13b3193 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,937 @@ +parameters: + ignoreErrors: + - + message: '#^Method Xoops\\ModuleTools\\Admin\\Export\:\:__construct\(\) has parameter \$fields with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/Export.php + + - + message: '#^Method Xoops\\ModuleTools\\Admin\\Export\:\:setNotDisplayFields\(\) has parameter \$fields with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/Export.php + + - + message: '#^Method Xoops\\ModuleTools\\Admin\\Export\:\:setOuptutMethods\(\) has parameter \$methods with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/Export.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\Export\:\:\$excluded type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/Export.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\Export\:\:\$outputMethods type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/Export.php + + - + message: '#^Method Xoops\\ModuleTools\\Admin\\ObjectColumn\:\:__construct\(\) has parameter \$parameters with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/ObjectColumn.php + + - + message: '#^Method Xoops\\ModuleTools\\Admin\\ObjectTable\:\:__construct\(\) has parameter \$actions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/ObjectTable.php + + - + message: '#^Method Xoops\\ModuleTools\\Admin\\ObjectTable\:\:addWithSelectedActions\(\) has parameter \$actions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/ObjectTable.php + + - + message: '#^Method Xoops\\ModuleTools\\Admin\\ObjectTable\:\:setObjects\(\) has parameter \$objects with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/ObjectTable.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\ObjectTable\:\:\$filters type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/ObjectTable.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\ObjectTable\:\:\$introButtons type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/ObjectTable.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\ObjectTable\:\:\$objects type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/ObjectTable.php + + - + message: '#^Method Xoops\\ModuleTools\\Admin\\SingleView\:\:__construct\(\) has parameter \$actions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Admin/SingleView.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\SingleView\:\:\$actions is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Admin/SingleView.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\SingleView\:\:\$headerAsRow is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Admin/SingleView.php + + - + message: '#^Property Xoops\\ModuleTools\\Admin\\SingleView\:\:\$userSide is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Admin/SingleView.php + + - + message: '#^Instanceof between mysqli_result and mysqli_result will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 3 + path: src/Common/Blocksadmin.php + + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 3 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:isBlockCloned\(\) has parameter \$bmodule with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:isBlockCloned\(\) has parameter \$groups with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:isBlockCloned\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$bcachetime with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$bid with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$bmodule with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$groups with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$oldbcachetime with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$oldbmodule with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$oldgroups with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$oldside with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$oldtitle with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$oldvisible with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$oldweight with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$side with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$title with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$visible with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:orderBlock\(\) has parameter \$weight with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:render\(\) has parameter \$block with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:setOrder\(\) has parameter \$bmodule with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:updateBlock\(\) has parameter \$bmodule with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:updateBlock\(\) has parameter \$groups with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Blocksadmin\:\:updateBlock\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^PHPDoc tag @param for parameter \$helper with type Xmf\\Module\\Helper\|null is not subtype of native type Xmf\\Module\\Helper\.$#' + identifier: parameter.phpDocType + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^PHPDoc type for property Xoops\\ModuleTools\\Common\\Blocksadmin\:\:\$helper with type Xmf\\Module\\Helper\|null is not subtype of native type Xmf\\Module\\Helper\.$#' + identifier: property.phpDocType + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\Blocksadmin\:\:\$db \(XoopsMySQLDatabase\|null\) does not accept XoopsDatabase\.$#' + identifier: assign.propertyType + count: 1 + path: src/Common/Blocksadmin.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\Breadcrumb\:\:\$bread type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Breadcrumb.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Cloner\:\:copyTree\(\) has parameter \$replace with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Cloner.php + + - + message: '#^Instanceof between mysqli_result and mysqli_result will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 3 + path: src/Common/Db.php + + - + message: '#^Left side of && is always true\.$#' + identifier: booleanAnd.leftAlwaysTrue + count: 1 + path: src/Common/Db.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Db\:\:blockAddCatSelect\(\) has parameter \$cats with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Db.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DbInterface\:\:blockAddCatSelect\(\) has parameter \$cats with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/DbInterface.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DbInterface\:\:cloneRecord\(\) has parameter \$tableName with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/DbInterface.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DbInterface\:\:enumerate\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/DbInterface.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DirectoryChecker\:\:createDirectory\(\) has parameter \$target with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/DirectoryChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DirectoryChecker\:\:dirExists\(\) has parameter \$dir_path with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/DirectoryChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DirectoryChecker\:\:getDirectoryStatus\(\) has parameter \$path with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/DirectoryChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DirectoryChecker\:\:getDirectoryStatus\(\) has parameter \$redirectFile with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/DirectoryChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DirectoryChecker\:\:normalizeMode\(\) has parameter \$mode with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/DirectoryChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\DirectoryChecker\:\:setDirectoryPermissions\(\) has parameter \$target with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/DirectoryChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\FileChecker\:\:compareFiles\(\) has parameter \$file1_path with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/FileChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\FileChecker\:\:compareFiles\(\) has parameter \$file2_path with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/FileChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\FileChecker\:\:copyFile\(\) has parameter \$destination_path with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/FileChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\FileChecker\:\:copyFile\(\) has parameter \$source_path with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/FileChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\FileChecker\:\:fileExists\(\) has parameter \$file_path with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/FileChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\FileChecker\:\:normalizeMode\(\) has parameter \$mode with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/FileChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\FileChecker\:\:setFilePermissions\(\) has parameter \$target with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/FileChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\LetterChoice\:\:__construct\(\) has parameter \$alphabet with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\LetterChoice\:\:__construct\(\) has parameter \$modHelper with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\LetterChoice\:\:\$alphabet has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\LetterChoice\:\:\$criteria has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\LetterChoice\:\:\$extra has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\LetterChoice\:\:\$field_name has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\LetterChoice\:\:\$helper has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\LetterChoice\:\:\$url has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/LetterChoice.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Migrate\:\:convertIPAddresses\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: src/Common/Migrate.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Migrate\:\:moveDoColumns\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: src/Common/Migrate.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\Migrate\:\:\$renameTables has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/Migrate.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\ModuleFeedback\:\:\$content has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/ModuleFeedback.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\ModuleFeedback\:\:\$email has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/ModuleFeedback.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\ModuleFeedback\:\:\$name has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/ModuleFeedback.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\ModuleFeedback\:\:\$site has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/ModuleFeedback.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\ModuleFeedback\:\:\$type has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/ModuleFeedback.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\ObjectTree\:\:makeSelBox\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/ObjectTree.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\ObjectTree\:\:makeSelBoxOptionsArray\(\) has parameter \$optionsArray with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/ObjectTree.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\Output\:\:getEditor\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/Output.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:blockAddCatSelect\(\) has parameter \$cats with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:cloneRecord\(\) has parameter \$tableName with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:enumerate\(\) has parameter \$columnName with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:enumerate\(\) has parameter \$tableName with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:enumerate\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:getEditor\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:getModuleStats\(\) has parameter \$moduleStats with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:getModuleStats\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:metaDescription\(\) has parameter \$content with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:metaKeywords\(\) has parameter \$content with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:recurseCopy\(\) has parameter \$dst with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:recurseCopy\(\) has parameter \$src with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:selectSorting\(\) has parameter \$form_sort with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\SysUtility\:\:selectSorting\(\) has parameter \$text with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^PHPDoc tag @param for parameter \$module with type bool\|XoopsModule\|null is not subtype of native type XoopsModule\|null\.$#' + identifier: parameter.phpDocType + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Common/SysUtility.php + + - + message: '#^Cannot call method getConfigs\(\) on object\|false\.$#' + identifier: method.nonObject + count: 1 + path: src/Common/TestdataButtons.php + + - + message: '#^Cannot call method insertConfig\(\) on object\|false\.$#' + identifier: method.nonObject + count: 1 + path: src/Common/TestdataButtons.php + + - + message: '#^PHPDoc tag @var for variable \$config has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 2 + path: src/Common/TestdataButtons.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Dead catch \- Throwable is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\TestdataSample\:\:__construct\(\) has parameter \$modHelper with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\TestdataSample\:\:loadTableFromArrayWithReplace\(\) has parameter \$data with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\TestdataSample\:\:loadTableFromArrayWithReplace\(\) has parameter \$replace with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\TestdataSample\:\:\$language has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\TestdataSample\:\:\$moduleDirName has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\TestdataSample\:\:\$moduleDirNameUpper has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Property Xoops\\ModuleTools\\Common\\TestdataSample\:\:\$xoopsConfig has no type specified\.$#' + identifier: missingType.property + count: 1 + path: src/Common/TestdataSample.php + + - + message: '#^Instanceof between XoopsModule and XoopsModule will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 2 + path: src/Common/UpdateChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Common\\UpdateChecker\:\:checkVerModule\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Common/UpdateChecker.php + + - + message: '#^Ternary operator condition is always true\.$#' + identifier: ternary.alwaysTrue + count: 1 + path: src/Common/UpdateChecker.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:classifyTable\(\) has parameter \$table with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:eligibleTables\(\) has parameter \$tables with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:eligibleTables\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:generate\(\) has parameter \$allTables with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:generate\(\) has parameter \$tables with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:propertyMap\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:renderDocumentation\(\) has parameter \$allTables with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:renderDocumentation\(\) has parameter \$tables with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:renderEntity\(\) has parameter \$properties with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:renderEntity\(\) has parameter \$table with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:renderRepository\(\) has parameter \$table with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:renderSchema\(\) has parameter \$table with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:sampleRow\(\) has parameter \$table with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Method Xoops\\ModuleTools\\Internal\\Tools\\ConsumerBridgeGenerator\:\:sampleRow\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Parameter \#1 \$array \(list\\) of array_values is already a list, call has no effect\.$#' + identifier: arrayValues.list + count: 1 + path: src/Internal/Tools/ConsumerBridgeGenerator.php + + - + message: '#^Cannot call method getByDirname\(\) on object\|false\.$#' + identifier: method.nonObject + count: 1 + path: src/Module/Dependency.php + + - + message: '#^Method Xoops\\ModuleTools\\Module\\Dependency\:\:statusMessage\(\) has parameter \$status with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Module/Dependency.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:getControl\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:initVar\(\) has parameter \$displayOnForm with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:initVar\(\) has parameter \$formCaption with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:initVar\(\) has parameter \$formDescription with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:initVar\(\) has parameter \$multilingual with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:initVar\(\) has parameter \$persistent with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:initVar\(\) has parameter \$sortBy with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:setControl\(\) has parameter \$control with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:setErrors\(\) has parameter \$error with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:setErrors\(\) has parameter \$prefix with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\DynamicObject\:\:title\(\) has parameter \$format with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/DynamicObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\SeoObject\:\:meta_description\(\) has parameter \$format with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/SeoObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Object\\SeoObject\:\:meta_keywords\(\) has parameter \$format with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: src/Object/SeoObject.php + + - + message: '#^Method Xoops\\ModuleTools\\Permission\\ItemPermission\:\:grantedItems\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Permission/ItemPermission.php + + - + message: '#^Method Xoops\\ModuleTools\\Permission\\ItemPermission\:\:normalizeIds\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Permission/ItemPermission.php + + - + message: '#^Method Xoops\\ModuleTools\\Permission\\PermissionGatewayInterface\:\:itemIds\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Permission/PermissionGatewayInterface.php + + - + message: '#^Method Xoops\\ModuleTools\\Permission\\XoopsPermissionGateway\:\:itemIds\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Permission/XoopsPermissionGateway.php + + - + message: '#^Cannot call method getConfigsByCat\(\) on object\|false\.$#' + identifier: method.nonObject + count: 1 + path: src/Persistence/PersistableHandler.php + + - + message: '#^Method Xoops\\ModuleTools\\Persistence\\PersistableHandler\:\:getModuleConfig\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Persistence/PersistableHandler.php + + - + message: '#^Method Xoops\\ModuleTools\\Persistence\\PersistableHandler\:\:getObjects2\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Persistence/PersistableHandler.php + + - + message: '#^Method Xoops\\ModuleTools\\Persistence\\PersistableHandler\:\:getPermissions\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Persistence/PersistableHandler.php + + - + message: '#^Method Xoops\\ModuleTools\\Persistence\\PersistableHandler\:\:setUploaderConfig\(\) has parameter \$mimeTypes with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Persistence/PersistableHandler.php + + - + message: '#^Property Xoops\\ModuleTools\\Persistence\\PersistableHandler\:\:\$_allowedMimeTypes type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Persistence/PersistableHandler.php + + - + message: '#^Property Xoops\\ModuleTools\\Persistence\\PersistableHandler\:\:\$permissionsArray type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Persistence/PersistableHandler.php diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..53ae098 --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,13 @@ + __DIR__, 'XOOPS_TRUST_PATH' => __DIR__, 'XOOPS_URL' => 'https://localhost', '_CHARSET' => 'utf-8'] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } +} +require_once __DIR__ . '/resources/language/english/common.php'; +require_once __DIR__ . '/stubs/xoops-constants.php'; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..54d6bd4 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,21 @@ +# PHPStan — xoops/moduletools (XOOPS 2.8 / PHP 8.4) +includes: + - phpstan-baseline.neon + +parameters: + level: 6 + phpVersion: 80400 + paths: + - src + # XOOPS core classes are not composer-autoloadable during analysis; scanFiles + # registers them so extends/implements resolve (stubFiles would not). + scanFiles: + - stubs/xoops-core.stub + bootstrapFiles: + - phpstan-bootstrap.php + treatPhpDocTypesAsCertain: false + ignoreErrors: + # Language constants (_AM_*, _YES, …) are loaded at runtime by xoops_loadLanguage(). + - "#^Constant _[A-Z][A-Z0-9_]* not found.$#" + # XOOPS_ROOT_PATH is a placeholder during analysis; these requires point into a real core. + - identifier: requireOnce.fileNotFound diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ed40fd1 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..d0389ef --- /dev/null +++ b/rector.php @@ -0,0 +1,18 @@ +withCache(__DIR__ . '/.build/rector') + ->withPaths([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/scripts']) + // Reuse the PHPStan stubs so Rector resolves XoopsObject & co. (else parent:: calls get removed). + ->withPHPStanConfigs([__DIR__ . '/phpstan.neon']) + ->withPhpVersion(PhpVersion::PHP_84) + ->withPhpSets(php84: true) + // XOOPS-specific modernisation (DB query/exec split, Smarty PHP-API renames, …). + ->withSets([XoopsSetList::XOOPS]) + ->withSkip([__DIR__ . '/tests/fixtures']); diff --git a/resources/fonts/VeraBd.ttf b/resources/fonts/VeraBd.ttf new file mode 100644 index 0000000000000000000000000000000000000000..51d6111d722981a86766cc99c53f1956e5c0a229 GIT binary patch literal 58716 zcmdqKd3+Q_`Y>MA)!j4Gb7y8UnVd5TlK_DvB!n0a86XJ>2_c36;WQx!36R4aAOeEA z-U2Fu5m63ZL_~~$h{&Zz*8>(^K}AGl0d+N^>&vOrZ*S>(~Bj?wZl@|Yc+jK%+Z3BElYD#ad z*FXqWQo8vYLOe4F z(L4vw1XB}14&lF{@KMhxvB8yk&H?B0{pXB?bl>NDoeRW33jK9N5=>_K&ov~8yzD>M z5|g&xf37DXx_kZSMxBi`5k@GlgZ7(r8~o>##7Z{*IY+e8F#kCtN!|DPUgrV{k;eGz zh-82?-G8njC3@;V*OKtiW&U$LNeX|_e{KwlmF^u=SHGyCdQMf7yKh-PcXCovihK4V z_vq@T#-@gf(wYJ8oZ7Mk_o(^v-Gx}I(Op>4SkbVcqCBCyj(c)NL#caWX>Fr>blv>& z!ixD7rHvKtK?#GBy6a#StT7O4{Eyl+O25~rQR)V%He223E_F9Gl$KZ2ls3$B*HvDR zyHPSq1r-f7)s2nSb+vA&Uscgi0W{8OD6MU(C?DXiY^bQf2g|BT8|G9Da5vStOKTUo z>nj=>;eoo@O{LYf)wOe=oibn+)@-V(a97sV0yj#_%Ia$Bp*mJ*ssiZqtIH~Cf&9MF zSy&^w9{?+Nmo_%ml~tEQd+zeOvgVqK+NRPb#HX@)enq3ZFG5rva8InOY+6{_P!Ziv z!331Ip{~5StU>`(UJYWYp55G3q2PcmbU!e_U0qu?zquSSSyI4aRR`2EcVks4kTtu)&o@A% zy4H=idMK@{1aDKwN2K^&IF`z;ZCM^;K}zplE4n831X-CjuBX zy02?V)w*((X{GajAAtXv03JQi0KMAzi`>=Ms51=C+}Z^F z#$8%p51p)Z_I&6Bb&5O`#$3zzrmE5=cU5U4aKECqoBs+(Xs$=c%iYbj<$jcVkX39; zwBlO+i)0(YoF3m{jD3 z8ik|si>A6KWVuJ>Pj%wo>1t{8DEf>lL_~7^2g*&%E-wd>mCiy z2&pmH+dxh(hi%)&9*0D08toV=W(sRP_uIYs#hGYf!@auK>05oPo!PLT>vDrlf;GrV8Ilx_1 zI4UD^{HVg*0f^y*qU_89zJ z3rFP*a8E4A9Fv15z{i}z%rSs1)CArGC*UqX8Z#k(V&+Yg;0DwSGyww1&Q$0C0!G2# z7zHzh{Cpr6;S^0M1cvxgnUXUxbAWqPVa`O9NLJwlKo{i+PautxfS*t`C*O}R$_Vda z*|pjMweV>_7c(+P_$fEPAYH-SlUmo|gDY^c7? z?~gL^lOMOSm(;Rh8jQVij6F58X5>2n%r}c zQ{eZS0P38Cs-~v;p^1qL7cNYg9f-XWz!Z`(q>j{+MWlgLlR2acf<`y#OUg(;_)UgD zGl`_Y*=(rcCZnNF6KRA`1F0aTq=pQDyE&v5>LvF>lN>`iU8LMhUXTrCA^D}b-1a6S+2)saet5C4hp*am(I;K>Gf79lpmcO7ui ztz5Z5u2@##m*X6zrCMka<$*G+R7#PzYKvv?1mZ9UYG8R2+;_vBT15})6=;qA7U}?M z6O>g$DSqZCHQk^Ss%8aXHYr$OTZkFz5kf3e>eYY_s?Y-vu(ryZ`S4o?Euu~!hJ8si z$?Ap?t!OGfTdu%E+UuZxHNaEpb3+NQCbV<2u2W#Sp|o0&_q7xS;P>=sq)nAzv%*7!ACL~7BVT%CT@TP_E3g}(EiC^% zI`F>A^+dp@0j^LwYX4VZSHm^7tZGyMtN%=OK#nT5s-B@nqgCpybv+a@kH%1Uh!H3ccDQ|N9~I58J$lqndfbpq|69wWVVpcw(JMaoeV5o1-u z8ewQ3=%GbQYsk0RP!FwPj~sfW72qr4^jnKi3r`|!>=DR?9*zb2OApt2+kxKCA%)n# zRcXyu@aU~?Do5}R!vABLBCSY+D*qbgSG`8772wk!OC0MgQsi0*?Whzb{Dxcq>3Ou2 z=$TX+5Ch~r_UV9SL;SJdVLw%Eor*15TC}wR+4tzP+V@c3u)nLkZU!iL)k76Ni&l0H z@SsP_=RiHAsLFp2p9t7eq)^2K>#N+oo<~^npK0omxJp5frXi(nz~_I8!L{vN&yOBT z1N?7P`UJ`xTR;kWOBs7Zk5xx*yA__-0^iWS1o-TRTIwC7Rn(TXx9S~LNvq>z5Au=Yx1DQ-o6I)3i~utpyG>~ zfP4=0y9H2M-HW!l(c>V8(c1LzH-LBG&Tl<~Di!o!=+iMC zQgt8w8fwn9`rm`4O5N|-ag<9lJdOI?o6En)*`BxsdE6t%YdMLO{U-{p)jPDc7~>(X zNKK&E1^fm^Mrz-~SOhV|Cwlv1^_en=mr6lvuyxhfs@m0KVFM8V|FBxu_u3w+)W{k2 zSoM#U*XUg~$pn0}$OK3R7s1&?ILm^wDG*^6DrGrv&keC%A(TvpSUUsmX26|jsDUL| z9>LFi;*afTDRA?Zr|>!C9AY{OJ_Yby2(9J-gh}ueUg6zI@LK@&Cc;xH9>_n$ zHXrKd1B^oDcPx}62C5`fUX6iT1<*Fu7z=n6DOg}jNFCNhtcsv~6mSHe$5wNdyDDbL z6O=OI(gOuYOW3xG2l9WiA0Fxe(w_$h@)r3xQE4F)?v96hDqIz+8?Zu35O0+mli)f7 zY9b$yLWF^3C>7*qUUyBEZ>lb!Oh-YD@e1bnJW_%j>CuMXlmwt%E1lodj)1lyER-8^ zDo<%;B5*bnWQBOCckp@CE7bfkKtYejsd|t64%Ah-fbz*#BzqIcS(S(i&#iEwhYEx> zMUh00JX8rFhNu_VVt{8o(#JLt=Kwya&8TGoIrMM^`yc8UwuD-MSJ(pf5!5l15|#z} zr`kq9j+2yU15oj~YqcM>4Es@_Uf{X^WII$I1=>c~J+z~?A}>`8QOYVe{yW?rYo7@| z4!vc)-^Voq6g2`4j0aV}-xKSoeyDdWiW~{}%pQ*tu+?M1%c$PBcfFpw$XOf>q5tX` z)nVO$fBwDEcMjCYj0M)~9SH}b5jC1r$4cm#RQxeQ!kAr+2{HClqZBpPz^GguW#ec- z9jOLJF*rIz-+b-(uMu#>H~_Iw+Y0#q9#}Z`!n_AY4%ixUSj7fgQ@Qz{eC_okMjWZ) zxTFE#EL6^$6s&6Dx)iWLs90J}ZiDN<7)H$xAPqgT`foA_(DL8-kC9>{XplO_s#Z9R z@nr(Eg_I$t>Ub@{UzG!uN*Try0W6V^J=%w{_E6>7-f=0$O&GDOI;Ca?@E*1^2inB^ zR})|}lqABjP&pDHo>2XFPu?p*X{mRdpcWjz z2yfA!gmf;Q6!SIw2!1Dj2fq_)H1n1G9i$b$)47lM_55P~7{8blBW5%YKZpleN5|0^ zvW{CvGpLJZa7W1-fO91sN!QU~!Uw_!uYNLH7a1 zgxpPc@Fkkrd>C0LuH%czMdS_nh&u}WC{XzEAbC(6M1J8#@)OIWCHz+881V{kP(nSL zabgfzOUG&M0!j!=Ba4Y0O5dYIctbs)mR5`)Yxy|#G~mv0O9OnPi^x$foy{iem18yC zOIDM;&#0BSJBoe+ul{+rwJ4E?*D&N7%w}aY=30iU}S9s2-O*s!zzRlD`mO{B% zxx#ZsjnqIMf_p~go{=0TJT*`~1Wkfc!r*&3+|yHu#KJu+VesvPdw7RYo${ns`PRUi z2Szo@6XMRt6NFJVDk^jd$~UjnVhTl^a)&D4#CLTmyLuy&W!8CRfY2$kD+5@E%znK* zR`_)(yF8ZtyMz5Avwz9#lFa_k>+ENl{ZnQ?$?V5Sc2Qp9Zr}PFRFb?CgYv9sgJ_9Cx#i_3Up@ z<8K}8BY^*rmwot{LHIC+9s8?YI2OzPdNfG*tDPMUVn?9(NCf-9&fb6Tb>V%Pz4z`+ z;l0<{d&~KEeOLeD72ci6-u3Z+@v?Vh_I5dId)Okh$?UCg_NL6;;)^i)GKtY^%(kli3!T{aIqq z%IulVCgB;GZ8ovZK7JE0YEuW>2#;)xWE}HA0lG#idtesVuA+zbz!i4EEn+8|Y!q`-q6?d>HGMfz7zN?dEHc4hh zk!+%!72f0$3Om?MP;irr6-;mm1s!ZczEzmuV)<4!K9c3-+J!s^%N=JGa_wwfj!772 zWjQ96-ND9Y*@dwVmSty|9V}yvNysp>F(x*8lvfzt!A1ehQC{X7X%>7k8+oHi7-?oV zn%IcpMqxxS8*XI7%GpqvrQ6vJGE1|uA*rFl5HCv|Y!^~PS?Xbauw)bl+u7jdd`hxG zNU^gNAD?VsgOauggJhNj@RPQ%L<38(v4I1I3IjXX0Ebr?FqFlYv;O6*pUnC?Se(-; z#6_}LH}giaJ~6<6_&$*=#>%3JQHbtf9y9a!c(mJGs6!yvm^s< zPArBN;16#@bD0?fA;PPa*YcIZv#|C~L!$PBbB%>?M5M8Ms8+b2<4I!M@%BN)a=iU` zdy>uSv3fmLPbE(}8(CQA1$nK;bou87u^+~xQSfyy#BKu7ktm;;JtRIzR%m&qp`1u0 z%kkmu$?55XNaFd<_9W`DG7kfA9taStQe-)WR>>{GiLSTgS#)#P5qUkvu5YnztO_6^ zZjnB-Kv(hrk?&GYutNP)C9{>)7k@!I<4+1^?go7lEM3G$I zAeTTN4R>m6X8og)4jm(_tj4Iwi14t`5NEJ0$Z9d0j0Q=kRfQBP+TE7pN1TZF@bk&X zheKQb?QO!vZn9M+R9kwwlse=+nKWE(Eje8xUoJUQBL9PC zXUP|6VF@jeFJ}Rx56a8v9rCgh^2JZ(JLod}d`g2(&^x-e$rm9^Bpc{QQ!nSs}eW~5@uZz}nID(vbAO3>U)01cn zi;a!51_wK>aj~(fgNLN0r8wZqfp?w3!4A8qVOCLe*n@4>AwyCJ$Fi`^bm@0>jo&Zw z{P^C#&e6@~?^eK{TRwW}-fX6mpp^2_pfboA(qF`#9mub$Jq1tS7Iae{2)V8KC|FEvab z8tQWCLqfv5lJ9c1$#~gT~MlPp@ld zuXz=6*{KXBmW)+G&%OP4k_EJ3YaeD$38EKnaylIRfMR0B2Y z%{r}Q*6Oqa1|}r(@kwS%NnhG8&J1gNy-9Ie%JcexIy>PbABh;a$>iA(@pR~VQwy() z>reX72+(+aQlAKW|31bDCr<^Pv%k@57-Z>eZ##ax-SW;wMHN(au!2f}zPxk(m#;5c z;IO7En&M1Xd1d;+>P**I{6D}s0dGu=-l>hFWTx*=A~7>FuhW?&lU^66PYNb5V&h%9 z2q{7zp5!HoQlj1)J5m~{_i1vaTzzh0ZqhV5jhm#ICQZ{%>N_K0MpAL|&B-t^!OhW> zXsV@ZeR=Hir=y%B9bCgqkP;reNJ3X-zjtoH$y)?ZVo&$U+-s-&g*4`rGqnZ7mo-Y0%@NMsHg#z-oet zqpg1P2K8IzpBWG1Nh8`@@~q|=lZNtIqPOxE6IjQ#Wazl?q+)MlxzxcyNUg&j#CZAB zCmvnhLi;UWzD)l4-xPF>zVWXg0Jtp?NjAgw`SI?>7~x^O$IF7dDFW`gL2o162 zQd=_ofkq`!LC|=CDMFmrn4k`UpPz=I8m_F0IAJ*T5v1T9a3I7u{M%bmm9K+!J^hhJbn7<*_9 zjJ%b7#7SMhTruX3PS9HA_+O%dd! zBT10-iQ!CNcK!3TaN<`;9dTs#)pJ6k-@3;5Y`X1cw%xJb+!8{94NfsQ$Q}aZD2jO= zO=l9d#6owmTF^973-P##RrzK+s+TO8J9o+Ax!~ar%b&|9<-^oR<7h1PaqTqZ!Ug$T z`NFr~(hzxtTtipWM%qMI%YXwVus1^B0cVNy^*PuMp4%a;(2yNEZHO2OyB{Da0K92$ zLo5>8lP@VDiU&e%j8UwfpJ3944 zGz=n&FnV_M?HSd}$LG=P_<`*o-}dp#$jAGyp5re9wS7sRFP=EI+jMKCmyO#+_u8nJ z!?)X7W7dl;{eo>SJF&QeV=cif${yv`MfS6Fo(JY0Z&xipDB<}_=P^Ez{{=Q5dLMdy z-V?2a1*%7kiAnXJAkssIs7SMYk3S`Em-jV(bIbe_bDntSxo6gGeq`kXx6eE{tKqBp z;5j`HFmK!&Yrg!(>!tmMq|Gg>tiC*b=HyxZ`_WLh`_SRLo&$}9T{1!nXafiKZj8@O zJw|3E9>$Db!t|OQ0%a?7)F6>iQOg_bU;v@}wkIp1I*-9;kY7-Iz>{N;1~vmaG#uQ| z@-(`|N4K9iAwSkNmv8A>#ddTS%Kwmmq82(1xcD^m2*^5;2r|MK&1)jogl^YZ)>_xt zw`+D9xtGWaV@o8AA*nN1iXxUsRF?KO%SDB4?Ut{RY4UkM0(+(dSwkFlaxM3OX(!o% zt_1_8CQ&O*l3$TOU-s+b+fJ1}v}w~r6Zg*-PRJL&F&O0^Fa0cE9F$BGv$F1QUT|Oh zKuXN0M%p#Rkcee2XhxH;9q4$1zm9S)`IPaEu+^>zd&8`JMv}u zwEPBbrkQiz$Rt)>n1IX7x1^uvq5Q;}r)oPvvE z9PeU`^9sOD1|c|%aW$4N)c6}5;5CuhP7R}UrcbY7p+YQ6Wm!y^Y4bR!hkEYkE4uc{ z_j7SwgM;EZjU6b1EIG^wj_l(bOzQZ)Z9PR z5vvOgi({eYSWllM=mucvkVXDL893Gsyn{Y)EyuhV829n09aZfd?miGs*a86~bYM_$9FK(}fN~xb+KaYnz*E>zbGK z+qUe$J8vIcwyj_P0}p?B?%bCTAE1+_mXu7LdUFZNelE~v0@;VCve&q_OORz=!*2&& z+-_MP+~N)QYC>&J5*=z(WZ%}=d0y4bwm^4IgWw)IZ<+`ZErdb=6CNE)m0(v5hK|nI zTDbkNHDleh3-UKKooZ+w>XRRk_f;RJ%PJ~CDJm;H)E*D=PEMx!&;LoIwqr#tVu3owSKhIaVPjG00*Ae5AZU0 zUxe!Sz`O~eVpK3fIK}@3v@*(P5imp%*el|0fJk@^20%&_(hdQLj&smdDxB&Z&e}R; zfn!JI-SVCedaRuuL;OzCIN=mK<)_j}p5yk2&+%G;@O=fq#YW)`-9;z-4$_H!ikRipVGs*xT%AhdiiV9@GK03;aFwvnbI0 z2;w2xzPN}`f#XG&nLHF}dBnWNu*tR}@fF}bDu*k#TfqtyAO}W)}(E@flSsm*t17D!6SC^&OBXEp|!-CWlb#xmS z+Xrb@;%R3vfB;>~I4*4Q%2l^5TCqZ&Hg>|V@4nknF!rvlT-$q3mA{m8+n+`Zn=QZh${P7f`RqBf z3O1L{xohcCnsWdG;-yROdtuY;3%ASvl#iocN{|zY1G}ps61DhzBtjH45hA1-Bealh z6m)pbMer~c#4~LKF($65$@66 zuX}=DE3DD3)9ul|ru%@rP2c7|(7dTVsyj(Op`UOkH7B&6>k^gNi-8ZpgYj8iTW*$@ za{cHBTt9hf*H-$(5o(ct5>9l)b6&0xb2IxPF1a6gsfBS3EFAKAgUD;Z#`qOawRe}=)02EU5^af2j4p`g=}gu7 z##5_JF~e{|BVjT=tJ2C9$FpkdjD&uF2_yZ$n9A{#u5N6E%da3-N zoJV)l=A~cHo%dnmU)$UN+W6tT!n7OcCRzb=$(wFSlRwDGkS~AxjeI#H2lNwoDyAzu zb&>?1gB;eapofFCT(CrhftCaktYhl<11yo>FtsP`EO0?UK&{X$hRqb%<5WkcB5;cv z@j&~pmw)K`7hMa}>EjnxS5{WvD!0Nhm*3fW%XeqK{FcVRPFVT!bI;5Fu4pPnPXKuE zX8{k`pX{^qhsA^3VaPxpmLNR>(j?5Fh_MaFh~TThTkWwJ3yk%Uk%v%3^;`J8pEq|k zaC5qz;GVs53WB)P@^|prrjFvUjSrv=9f|i@yW0?`(g64`&=Z932d^*C*XyqV-wj+| z769Fa`?*oF9g5A*^?6;P!2#ihz82ms-tfpU*C0a*Nwf_V<0GLh2=Fl^Y*PkxU@ZND z25~3tyg3Tk>jfj{4+MENM-q@U2GT;u!}9xoR>ZxZ-noWa7cKqwlCOXL>Gbe9{l4dl z>$0*Gkyq2FQRHK@|_c%-RBD3GjY{QM!)%^bYO@%&&I6i!AE;3zrVPcM&j2l`uB~T4W^c zRq_Uht#pr}!AhBna$7A$)4^2nim8H(jNer4u?Rk)KwyfX_ak^T3WQ!=Ay+C8^)TA9 z={ce~EWAlp7zC|IgCTTo4-fgrg6q`$=opQXf9V< z#7z~7wKr=^xJsc$ThHAJ=60F(0bzyq1+5JdY|sU$M+b%!dH~2Eo*wEN#9VS@*A7Jv zXE;ySh|Y^#Uf1s4eN7-vpC0=ePlFj@kTz+<0si?wD2SkxZgeFdI*`&wHE1gg4k#UIA$Bpn?hNjk5j;TX{ZGxUN^3-d36Bo5-GK?JNF4spQ@LmR-6Hq0w^ zk2BC^scs9hmu6+WQK{q1TC*;Sb7-Os!imnaEUF`(&*(hGyjQ@CD7;YmkB%(y zMQDHxlp6v;iWiB0Lo0|>!wup!KsDMGC6%F49Y|u}*qDbb_fE40Gqr;yk?39G4RkCW zCr+kQ#RB~tS|yg~_t8C~$;E|gZsbxlIY7KmGl`p~spP6PdPVSnu~n7(4lat;$c0^J z6^x<#0LG+~U=#utN~}~yWKNRovxz%`$PUB$pq3EbKyw-!=!m~A$K=x!m(yl(CAvn! zEY3_Ct(qRpL9#%;349Q%Y<=_1)|cOW^JQ8^TV%+A$xq00=o9=&xwHLyxs&qWw^N=v z<#KtAyjCu!>*-uNkFHn!j^fKDc<06-U$9|^M!$os(AkU{sw7I{-AtwO?mlz zLgyp9fU%Es%8$*ETSXoi`_omG zuYT`Mphv4Bs)r{m^?f8)?n%34 z78^NjPGzxthx|(y_~>^&dF=-?6e9eC$Bb;5hP}uRpBBvxitEePk5x zpl|Vxw?cMU`5oT%)|S4Jl0GU-L&97pyT%pW*YX8SsQj4X=x+i>!;Pa@VEnzi`W*qEoz&4o2M@bo_5b`M5%G42Zk(GWdH*~QzFIr zk3ODcp8sI+cU2GkX=?Mczy3`=C4an9{`;y`RKMi*d#2yF=Bs1WO-+j_FKm(9(r(Br z7(OP%lYC_VzyCQTm1gFRFUrr#i}WP@ZReSvyuc+GZ3uHgA2j4fpGj0r`wb92-5|gq zSVC7<7^*glZOThNM? zr1+dxy%q+@!eLX3j;sjM221Ja{eqOvgPs?%#c1gF_-ysI4K_8NQiptCxWLaszW_h8 z=>7*5fvbHJ#$K<>Z-T+S3>4@k>0}LZ8^gPPIT^%sNl=$yE8$U0s3NJJYFYw(){!!8Z!dW;s*UCYbL(7~r zgoW@m8(TFh2ytpQI$Jrcc(K13g+cXfR#rwS*o?Bw$?Vj&|4 z(Skc6270S+oEQNfQ~)x-gGxj6640X?0@Ub-Kz2?VqLUy$$3-xh>o6c?2CoD`hKP<; zYk(OYt-8EHuOY_niF24U|9z+0GcF7`S)f}7Iv^EI&u!J?mqFoxQQ?3QGQWNf9AT0J zcwiSSf<^L5Za9X5A08@<6jG!lIDE=6i<>2sN_)ANrB*l~>mh;n*XabkXb9#)n3H!2 z4y|1mst+~9aeY}F-&cT%*lWO(VhC0JSworSkZh`(*!+n~& zwf7rd<@T{X{9fT@?Lk9sKtdQQq0b*dZvoFuU@C;bZ>_vh&VYgYB{>6R_7*<^Kj1{` zt~>&=iv#7tthAn7@n!N6sA3Ty4;;$Fuv!%&kEDYzKpUaah@heHekTGtO1v<3 zC%mM2gQv=wMo%>YOnbF`fl-he8TbWO?w|6&zH3I2sY-i&AnPmEGBXo( zL>I*@xf;+kLcCLgM3r@ zRP9iyz*s@%igm``88`;T>UqFg6G13AJPI5W{lA$-ZAk8vGcB9 z>)=kwQS>a>jH~Tnna%+Bv}B{N@3noGabSE;7XuBOEA6#=Ml=hv_DIwLs^nEL8_Bb-Iza; zw%X~RArdU2Te|+DgwgGsy>nyNgIqI6bq3Ib(Z7x?^o26)$y(3`&Z~uRnjID({)Nn= z2#ax0#N7kuUOrVx&G>sDU@a+FOJKqM{VqyidaZ+nYQ3zVb_h$?j?`zdZ0!X7WOlQ@ zPH%=72r}?6MM^!5{5PFv*bN=uGfyXs>Hg7~CZGNXdk?V0dFu#R%>pZqd=|bwD>Hv{0t;*pj~i7N0jsi##WfuL<&6I<*wB*t?>=C z{qtF&1Y14Ewh9-3zak0oL5`V{6)*`QKpyspl21GW=A1&f4u9+fXZfdGDbM+t125GD z>s;iIIhdyb%;gAJj*K~B({2<8mxv?o<1h*9@T6jBET1}f5bDk1FLJBJN~o9rDt(8% z%~KeeDig>s@Fl99RDI?3LsP|w{?3qfcPJOiIOr^Z;UXH^Ca)4J<@@NZ7>gIf9O2ur z23{htaK_hPClXhbp8U;uOx$4l(CR)C^+EWCnD?zM1`^|BAx521KO%}5?ZaY0P;KD7 zVWLgZCv`IEYgkJI;Ton5e0hm+BitiABjd)q$9rbFXL@QOWVzjayQeh$@RLiMl&-dDQC2hod$}Zj9O)*&1cR2qrK`HG+CU zOptlPm^k_RV$$vjktLCs80$56!eA0N8K=bo;W$`H;9;b3 z(V|9J@w0f`?&f1ul7BhYynCFyj#hrNb>qgZ&u`f9Ja=ODO!-y03yxQ3&fW~@fM%eq zQbATOnCA;6N9b|hbi{aE+GOQ7IYC6Bn$bpLA9iid?$SkA)|IsT=CEa$uj`2dF-K?Z zmA&Irb5>7S``mMDiyrqCy*LHZ4BKGTnOOV+KSKT@Iq9XRo_Z;HkbEjC3bKn1IMQ(0 zADIEXG68ZHl~>M9UXhS_;y0Wx18_c6~!^5 zB2Q!zB->eU=54{n4C8h-ACrHf(y^vpn~`@7ix#o{T=B2%o6Ba<91813XwJ;e_mF$| zQT1AofLC@HSB>#`h~qP=JEA=;PIcK zB~^D?xRlD*`b1h00JULqdJhYJ#U$_N!L1;ILbOU+D z1k+iZ4Wd7tU^Hf$^>)h_!`op!N}D<*b3Pg8wQ#8q2GGXt0NSpYZaWa}H{Ci5xbWqg z^mEhGOb>D*4bGS!~!(b7CY9Fw7 zoL6NIQ{g!DflkuDq`yCVtV}s76D&&njf)BtKBuxkcZcrHwtxa0l`m1l(dM1wKp|d` z_gA--&DuS^_1X5iCAT)#FIjSM_6$0v;|d)$qijp3RsLB%=k`$Nkkob0GV$59>oz>Q zX6>`U?d{+xgMiyklIjaF@`OsKf zm(qhgsa9A52*XD}5H%OqXe0*b>@UydF5raiT9zQ=9rnp{VpSX~<$6Zhg1 zT_;6**VfrHFQScs&VyLsENDL%8=Zz(&}TT<6$39wy=E5Frz_Bv=<0RLbuc9mNG)%t zmDq;7jxV{gQMAin5bo-JIZJ`4CuUfu;KKal2Ehbyh0hJoSRe^$q(%d=Sd@8Db^Q+r z1vCgVkY0l`|JJD% zvAg+ug$Feo*cw>f`lRMH78=Y23q!RT+*o0pb}~0pI|l-%3Sl8v&n*y^Xz%6j6CTt) z$~`VTsl_#Zy;6{XKV*6~cEOCXoG<6f>qL9!%k)Vwt()n`@<5dazceCWCC_lMH_6hS z3kKE*B|^QhT<{A(VZLbp6+%c#w!s@*cY~Gjk|tljFh`J%*ATa;2{E5?!-kyqBX@;E z2et)8ZVbwf3NuDnz;$*G$A|;{Bu3lA&*Q37@T716gP=Q`agr=n>B+IM)YIRS z%~5elaRqVpam(W#j(aIiGZPjHf$E_a_N*B4fOZB{)1j2}`!Zgyf8%{v5=OJ~E9*G9 z#aB3|9xkg!zc8n1C)-k0bMai)WG>qn9=dSe)(u^sbJ_dmJ^xhKNxtOSn@jM0u}ZH2 zIy%E49>3{MF=tbfe(zHXOB5{4!u{MGB%us}p!>NK{m$SIy=rF_!&^<9NshE5E@D zF;*a>H!-XbQhEi^0pvVuQ%w8 zI)EIQ=+7wvyl7r`@O9 zC+*YkGwd^ZO`=I-(wcN8$*4~=j_h}HKOH72dZqmM?j?JX7{zp_{BUgp?`-_!=E}0% z(vj455Y}>a)cvq@-r1(=xj8i>e>{Atv+Q$-0e^y5F{j2S=yfq0U)Z%fCWcxD4<4GH zlxWmOZvOL5SWE?c4TDJLS>b7jWN!6^ngp$xZL-o`+D$~#>UEqBx`QRiR0Mf+Wqqwb zj)5ikF#UpFNU?j$w40Jk$ZZW$M$o?rXhKGkkv81n%*+vv`2X2sf3*tJ3g z7vLk3Z?DfUvTO2-Xa4v3MU}I^$uBMerb2cNbBvgqn9Nmj%ejZSjgX;GkHg$y?n5}f zQjVXvpE#F5z+p0pcMuy5XQ6y7Y$}Ln{rDkdFimIae3CYcWKnSRe2Ml}@m_i#yH~hR zTno$8o?uV#YlL;;R(mC>2c_ClY*}=ps8$m+Z;B6y-BL=$S zx_sqeHD5Ux^OZ0u#7TqyFkgA*|5m;d^IrccRr$38l9R9nUPzM0DoM$krTNk_NnxbW zDzr+#OyMKxLkXBE{3tntIXeuPf&`0*0eg_nE(PmjX&=}Z-%sqXjo0c0~p#6vLYyEeI>5ACGs0aS2M^8Q3bjJPzG#)-PU}@u`1N-Gg zAi_?@ckx{3m5q$+l(Dy=c1s{jJ^hm}>yL7lz4Mwq`O828lc#4Sjgr8GR1DQ)0`vH9 z5}4g9kOG;@Kt{7y=D9nA3Ed*M)vj2b-=r}=2Xmvduqd6ICF)YxaNQV|rz>PLbhFuf zT_d|qcL!SzE0I^}9w!g8huP!edff)yM)s1fmEmznhsR-dm>ts{(|yQ3WM_0|bYHQr z*pIp&b^nHU2K-yRs!N9aIuF|-BpVmbhih$8lo1qwkJt8*Kgr{qKK4F8#UM*E@k z4f%@uk^ffwQTvtjEBQqn3L=CoDhLq1L4x!z@_f4Z!nbrW{BD)+?)+80n;XH!$h&D? z*O{(2=xq6Eke~}jg+U-eGacm{V~!wZ=z9huGvR9b>r<*`s0Ariv)3d+T8u~=w4XDw z3-;XUVaFZJjeu{k_!pC@b2OMl`rRoKoH~F_Mq1pGrOq4m5}HMcm@N>N`6Fg{b4I|R zVXC!)YvS$!llGWm)OPnWYRCOXEd)~HOc21LY6NXCbqEg8q3I*^5&LMc=iR`Dh(k2# zrgU>A$)w}hIGAwB6z6ai;yoNJHWTkN-e-E8TMJ{BCyY;+wsPCpHvW0j^X6CRezuoy z)wN28^sgCTGrh}wVEn-Jp7|5*0+-%x_)JtCc8!ELZdB5N;Fv1qeq#?jd8~f=L??8X zE-iQY%8=LBd_Oc7x{s3e<+DHnI`XtHR7vR(0 z^WXjaWaf%V(`xIcPFhiOw)Ks}Yn#tBtv&k2OJ}F7-15{)SJ=w+TUJd03V1n!egp6D zfPFc8U;__sScN$Qf07qM5&F$JG2${zI$T3-i7z?I8y0LZYs2&omzfvbjD)qhfZCX^ z%x%_Zyy31;t;0-Xf_M@d<$x5Ko>$0C?i_wZ4LcR`uAvvng+s~23K;MKk!i3@JwUZe zWH;3i&ylQ$CQYlWn>Oj8tlpH@Oj)&M{mL-c%BQxhoO1T1H;%4tI@7%N@Efh5e<$fq zSmj(uB1nu6lp@Rk`;eXsGdyRDB<4uy&PaLt7wwkAh$W=CaXoEZEFQpYO)4%McEa)i zC#*jhBAlALt7P6PlU8GXa#r#7+4FY7m05#Vlb>gWmkKh6ij0XP#!u`%%_Uc_;vBOW zr77>IsMko(mBgk%v!I1Wc59 zBHSLBD)V|YupDwnRQTVG!Kan?r%Mzur!RSB)Yvr0`p|(j96=9_`#Qv7!ut7jk zI+fwTcO2Ef^Ytf?L4*>&9rHV&|GY3ZTCrfTJ1Ahm4ig8w!Nr;AN%Rc$)OnV%W&Et0DVb`$7+e9ST1bu`lwF=a45%gsD9zb%{|iZk=0l>l4k1 zmPD)D=C&t>B)Sqq-I38z@oslaCdt)IBGWV_x)O*G>dbYPI%|oo#9kLt=c)@WiL8vO zc9+C7k!H4tzfH3^s>yvfc|db_)C2A{WSs^rTC2X*aL9DXe8_Uh+G=aH9|}3-Iu!b9 zL~G=(sQvB?)w#g7XpgeS00$h<&Ah$VSwk7G<)Y-Jv(!bmQO@A!7Bp=QmKTm6GGu&y z>X7l{VEL`Ew1F z{6n3KQ%aQC6tNPZWvC^v0)L-^!EZ~({9(zb*OUzE1#RUuCF#9NKI>hg3F}qzMW95k z!`TBU$@1fN8r~v=+yUHRSsY>w5jOnO0M;k}pk&M+mVA0m31S_9w$fh$StRCC+W92V z&eet3&Q*Bl5@g^IgFwj`e+dMO+)vk(e5#g^;ZU+u#C8JDtn`;C*(wpabCbWF&)`i{ z0ZQW(XoUd`;C)lSUoz$oOFq4(1ThFeTX{_hVh||#%wM96G8GJpyD?C4`)$b>Ki0o3 z`Sh9+#2^4|C6<73OT9Xb;i?!t>F*2H@)tPq94x8j&KuYiflk&W%W|^IE5?q#Eh%x(pn*wC zGFBXVLzq#Xojtp3YfU|bwSruY&<>{iiY zBzg_Z?&z&YEXR$T>{0qnp2+v@Ex~>4dX3v*&{#B)PQpfq^|2g>?ST*`s1uh!I;^A{ z4%_u`wg)mc{um(%wtnCcUfBv40~W~m@2x|xn$&87`lJ-_C${w!VoA}P76nkFa8ry||*dFt*9rjWZt z!RtoJXBh;>6yA+R_-|lESs3y8Ji!oX;Wn-y2$cEluJsnfcDvR@j5@t0)NFHD!*K%; z?on1&h$A(M{1ghn(W)Ex9JsRxWr=$oQ&UncLtv3Ue=6&-@e6LD&GLg>=#{nS-h5N0 zwi&Mo`{cjJ#V);T+mEp13Fg^o){on$`7$JQIAXdwf?Wp8QpiX$&v!$p&q)UHLkH`v z-grKY@?nwRI{9x0t%t?CBRuh;-n79(JsRr>qM4E4nPDF>otP5Rrib?R;mZ~PUs%rv za}Elll^7pRQ3`;+{5GsQcdFqV+=X~S*@x(`V}yV{14A?AwF_~n%8s?v!5B*51|5uS zlx;eGxvB4=XfAs?JvVQEsPS(bZfPE$`}2uOuT(bfNgtKJyN~UIjdi!=j=Jz((QbKN z#+b!>MrA!9>^T<~Hz;w;1idiz#ih*~lKS0}^T=}%5kK^cPwtyF&ZM2P>&~imDJgZi z4{wg>x;s2CdCct5&IyCZ%*A*vjy}SEfmqxLJGg`X0b7w~k`RCJ2P+EzMT~X=ged9{ zsM9!*%bhtncO?%_g&6d%aXEKFD8zM)yE8c@bx88yJ95U|m68Harrd=(a>vzSd^z-K zBZ>6|!G?d>jsve>(`)D~rkN$c7JXzcq}7n2uvH#b7T_!&9>||Q@J>7J!2<`6dqfoJ`{JBDX(0B z%^1CQobhDQhg0z1L+sbq?{Cyp+6NRHN!bk*?qYjY#yFLG$zUOHtcv^sqagcVRMTS+aJ-PFj3)fu zKJE{=8TSVa6XMhB(r;OIdxNhrVbonWek{u$XD+|^J4{^k2i)Q-KlR2eZ)m)|Pt1v6 z=XYkD7GS%zhmLTbKCCEyMOyFJGO5ZPMkZ}b&$St6>Nl@ z%Z*+okEgpB)gMRr1-w$!}>M!tK5E#e89JV2woBeueRb~qR33_=!KINZsUeo+`F7$Ijju1Z62Fblj87bzy-6ow3t*+n&%{Q zcCLBkdwJFluAuX~&b5!W)2*#tTiGc(Xz6a)^eZ1*w(|#=Q|B-))Ve@37~rwVmj;_L zZSbxN-WCKW)(9JK^AeGVq@HC56%R`X`4xu4G{_VLllMfh={3P-PS6=`iO1X85HCd^ z692!-z68FCBKf~(-psp?d3Uatmy;x5NJt1F2jPK;+;|`YBA49vDRKr>JeK9Bcp{4e z$|~!cfB{7bqO4#xigNGjiU*6XZgA0E6&1+K|64Whg#hmF^ZQSjdc#alcXf4jb$3lw zbtS&DP-G(>d|P!uqzvT+=@s^WSABCyG+-6#mlBnB(yh{c;y!7I*de{*e#K)a)gjz{ zc%l)5SkNs2zbuL_Q`a`nlP5N>C&!(r=h5gIUF;!!=&3MJR@}BuAdl(7s)2lA*Spqc&+nyLH!exTd`?sHLT5auQ=+ zPzxEu#{A4uEkD@d8jMC4zj=$N8K{4obvfJIpVi*d@Q|8{pHd$wjLa7KL6K5BRR4s;NhuT3r)E#hO-x7#X61zP^16h& z=JogW4qcblH*bV*l>g?Gp`oE^L(+$4+?G4lGQm65H{Cxr1)F-NPs_c_@|0zr^+ii6 z8MVg6z|Ul7j3BL(VTz8~LTS(hpHEz4Of==lBRk)7^UHs7X#&XLV@Zk66fc9^T-ReInfDlH&0?{*)34iNki^xh){H~mI|M&UHKe)b6|4*SE zw>-va?LE^{3wg2uy3#05#I9sD50M(e1`$H^Q4FKD8k@z@onU<4Pg5bD5a|;-!=4Uz z_8l>2(tth-qyO$OzyF9i69-W=H58U5P>+kSi76LP0(drcTT zaKeP7Yp)%T-zj77UjF@#Jxy~Sx_9}1cD`{2;}5*=@ohnm?&E?NX@dGA0{n;{5hUaV zr$e5OC_i@dbgK~x=UA_$!3Ni;1Ln>&Y1U)?NYr^R#rtVK$3FT>^qFs>&q(9Gk;YwU zqSX+WrfdZrts*DlV@r6|Y7#G4&q?^8D9keaq#-DZcs_LTAene%!{ie|jPxJ^&`^Ce~$n{6rV<@iy&nZ#Lh@hE#&sNNr~^FAI)WV*qtGV z!=CMO*c9M5xRXvj~EdBwe4&Mggb9RmDv}!(QvX;^jW{VR+=(? z;-vA?VE&vr^X1je-s`(s?dOAm{x=UETu>PG zfYT~kzCtty(zoWpGBru$q+61WIew!nJ=x{|rM*2X!5~sSSqbf7E`0JJi#I|J*V+f# z2WS9l11TQ2fz4;TK>FyGsR$E>UTGrZp_ogU9Q6k(ax(vPb75q6_0b=r^)0V9eY<1A zYH#0OV^;R0WQf9`ruvflFj+@;ZPkIugfp`St5 zyy!ypQFR~W%iu_YRk6$J1(SS1oHyC6D*RLxurX;PV4JRgzS9svhsP-h;Hi@2%X6KYCyYcpIg)qOv| zYRvlu$|9-d6ZD*ZN-x;36u{2GVX&wOS7UHcXoQ#-jK?jUh+uXw?#2c)bKS(jl!@q( zd}7LKf(TUm>AOt zuD!@aINCbV650(7wnGw;fVagI&DVm~_PMZcQmk`=bJON1`S3S`&mawPyePa#YZDk| z6{HG zKs$*)BP}re6WRn=!Gnu8MzpM)G>26QWhQ$xt1DTNUz5yrLP_r7luSaC5J8kZ#8s%l zVO%fL@ISw#M1naRK!pU!8*B9WOtq!w>$4r5s%ST zQbk61+YR{_k4eW?P3~Dfe6D2wvGlj2)^0ia_XmgHa_jShNB*Jz=O4=5{w(kBTvc2? z_twCWXry{TpZn&ox!2dVA~SPmO3F4+WC3FHJ3$+=;Y=1~J*>h>JwsBlL%a-!GJGVJ zH89?*a={L15c>Rj~jkM}|x7+J%CYw$=dcEcZO|)49L=<)@$CF_d0x zlr{{x?Y1E|-*%h&-KTr^o;6Yjxl2{}Kw&3xi=>5@#Ezu^>5;|=}j9mgPp<7gmgOQ$HtGa06RAD;W zMSK{}IoR%arekB5L;3AJ2dmzzFl-F}H6hvmZeja%-Lcv-iu{DME}FHSaHN)Mjx?*o zQ(V!V?9z^7Fk>@u3~ww?Nx(w*?}sK8%8&j3h*px_I5>@FLd0Q%sytQR?va6>f!@K9 zQJzuW(UF;+nci8E>c|?;8t>Ddr@aqHYCJXGH#~2614Z2mt9o{eRNd6=hN^Mh##G(Y zZAH~1-5##m(rr^!UAOI3>%QWXv*>C<{& zUI#~763^=$o7)*SC%xNlU55_W1XI&8HB9dHiG3(j%L^LATF=Y@#-727*vwwXqIn?>7I?(2(?7a;J!C z{)wRp=@W7TC86@X>q7nVZV8RbonoC3nvrXv`G&58()CpO>^A4W&D0IULj4OCE;E_> zga;QcSZXpgS6tf*-e?(9cl3XB#+u%2X*&JYfsf5L^h!zo#i^M5xAkc25T~s-y8WuiyqR618Ge$eB9~A<>SkXM;7$Hp;yo7I|%8t z_m1BUEAKWS|7~eJOvK1tq)n7)8BwB5YXnx(3T)Y|LZDo!$o?=AN*XJ<4KCclJH^QpC00C+6z}!JY;3`xQnr0frF_Xs> z_{@cW8rC#FBCVwN2DJQD9bv4)Y<*^=Jv`-T!Vcd#mUoC?J+rEwl^g64P#B$~2-8}%7(T*X4as;+J^lePu=2Do6Eaa#{@QG5@@K}n$A8&v7*x8lx_K30XoLlCDaQItu`tpX8V7Q(jm<3JLzJGzajO%O zQcQMJFhLF^rr7PK>_ofQYDzVWm!!uO&r9CN0!jFI5ZLHaHl{!4`ZUFyMM`0gg@!LZ z_)?&MEI>zTot9(NWb2wDpsMTMO{~Ar=dHTVz0^y(3|_jar5%T~A2B{TT?$2auFCuJ z%^s_Ib-X2S_#L4I|B=#*+n?Us^Ir9vXeedgnyqJ>M}Kh8=jZ>(1J7Rk;5{F&oK7L$ zS|<3p0@lFk9m8eq$%??0+6lqnbW4Ja7s}ed(8*hwD)L>Gyo^HIVuXiP!P%4O>V$v% zgo2PzrH=wRG_+h{Vc`8=pI&KyFlEQsnU7jkvypW4Hd%9ef+y-EF{-SPHeOKWdGGXTxAvkyZzF>1!B9ZHDuDqF@cRd z5tmc8s}^B3EwUTLBKkl;qZj!Z@N^JM7W6q#O<#!WKl1A6$55g_oB~%hPMFN*y*nl7D*`cd#nB`3OOcl04DG=4#&$+P%8J&I%BnlMkwjJUA^-fA@>;0XfQ z!T&_DYbP)2ib6P6;4>e8)TAW>o$5LACOiKULHR|36ec%*(s$si?MX;0OZEWz6S5SD zTbf|n-^Nmao)l5*!X7{+O99YnVoTJ#PyH(tl3_#$%LsZy7ROtzy*3JLhvi2NgdMYD+<(h6t$F9D;#Z>zV2aAD~#Agx|ZHOeQ^5dbc%zLN%1U{bOfWP z8I=B)Va(M18}O}f!xZ>4{2h_@)2572Cbw$hRyqpo(caN^(%+=rwnC-u?_YfRg;IA( zBPUC%5UF#rbPp`7c^*Zc4n|nZ7sJb?C?jkLXm*qM(5xMF9k*}nxY?V#+5A_-#_aIB ziQaXcjUtfI$s!ysQzwU|L(i0o&Qzz1kjK2uSGyLAr_DdWBmZoH}CewZ2eJby>b3q+sZENq?2+<5DdA#t~ln}*(cWAvT!xL*j# zX(4S_V1ocEd0)ra;NK}IaC5;s5K*wRqxK-TUb!yH7OzNc{*}}RFQqn%pJSq5Ya4#HFkp{+>!;>M{X1G`oZG|wdnB=C>O9haXWS=(4mqZed+Xx z@lhlH7=}s7WV(rX!69i(bQAUCJt$??rBW=AMkvLIuvHeT$!&z^F-ARr6H(r90!sYABmwDV`_0l z@}iJ-x(I@rvJ{NC1q@})&)Y4FRcVQNvBDM+q`{&bDdIDQw~loy70W1jpHv)upU!9k zsRE_d(f5>L(f3w<143YfcZ?ptpX{K`<*!Cm^9Pod7iuSi>O0#uUN9 zB@_I`pa3ytV-jSBPT$B|guwU|+6aymf*`o)9r_U6c3N@u_3#XfNjguVhA1YP^h4Ah zwW}*H9=g~7BmC?OA7418Vof9gUU6_5aHWbAS%DtQZwRGJUR#!-hgC`xl9T|OJp}WlZw{~#Pl&X0 z6{h&Sp=7_3;E@ZhsdkSITU#g)OP*!Er3uUP#WL$sV<9*KUt(~Q)OOzbXbz| zqsx{DG-<<>Ot&B?%_-H@(xiFUZ4QE_9G?G_w(&`p>JB}1C?zE|^~h6)3a5ALGQI53 zxd-w6sd)a_Xt%WUA)Sj4w(%xwcBJBbe+2h^k#r3fsZCW?(^lm}pj)<$0QRtRn6HP(2#y#84l@{eN^F#qB)gZK__ zE4}$`9`{;Dq^tnO{s8Ud7Z{WDFyXwjOnlx;lOD7UwZ}Q=hl(y2mTgsZmu)Z8W=?b2eF#@W z8#Q0sZD%YEw6Dzd0|s2b`@~;f-~HiVe?Ab~Xa+ZU;~D9G+K0v++m&W_Y?qo}*@i~$ z8sB?nYkW7FU%odwn$zO@)NGCKT=VO5XH#s3yyq(WTT{&b_UidIrck<-?N#p>F4^o} zVa;p2^hB=XG3=Vy7^bw0r`%f3fVn=o?1@Q#0xh(VH(vI{5PK59lWvziF~*);Z5yn_ zY=f_UV*0O7jIk$suCgaqV)n#WKQaB+Cq{aL{s-$nkyX$X*-b|jQjWh2cIVz8tTZZf0lwh!hwOsZvVB|VRi z?so-TL05t+(UoLL#Rw!(q#WH9H`$e}CA*V7$=+mNvOgRM2g3>B#BfqlYOdeL%aWLy zAtYt8?IGE(L3h?oDrO@^SuRzK1&LYhR!_P;t8ZSnK(;F@m#(i>hh)o2xT?bZz+=g& z9b8(Z3|9%D(gAfCtY2u%z3vr~v++G(AZ26bF0r^jB7)TLFS%so;v^%NE8_vt*NX%4 zoz1^7`p^Lpe@P7)ZZP(SreI3Mt{_yK)3nkIrI>CxNHe6$OGU9!jUEEm$>lj=3HlaC z>jpAxRD=H`SKN=2+D(356p z_wLuP_rp&i9<{Vkt&ksN%b)hU|~e8JJ`_A<;;`7HA=uYah-YE zhJ!b8E9)Q3q-6uUln$VC{R?;A`2wA4lx~nmg8u&(Kye8H$>jGSi7;!9V0Sr(QFtvv zGgx3cL>~u9XMG$CIC6dF3SMbu)Qe8N)vs83*dU=ly`BhU3 zM&IUetkYZ_bCYhup1t*!X!v_496>-3i6?87QbDR%+Q$!D3~GppFyV5#;{bRJnpJ@$~4iFNCk*Y`!g!-D-2**lt$s<6YVyO|Ku4}f+=Pn9@)Kk)7`#S(_Z^wY~cqE)Ey`EwaZ#mYeZzb7{NlwI< zv*i0aB63?G*>Nw_bI6#i6Z9~in92R65>vUzFp+*k`j~V>fm9$D7z&hvl!8z}YC(9g zG*}*N7_1CV85|m%IygL98ZD1Dj8;acj1G-X9Ua~%ZIm|}HYyuaHikB)ZVbOEy(zzG zfa@+Cx!z2DGpt8$B^yosi&LhQepzsPdLZ9$=FEc};{KfUxO9s&;I7G&CQV+%A?~Nu z)`^k8;SHLNS0-g!aw-E$W#bv(CVYHPfhe97E# zP}bh)>TIcak2GeMbXR8dfc#Z*@~TCTZT;is{E^aQ4@6rYi${_9O0qbdA1_=v_TBk& zSC090-HSrHmsWiZc}Afp;2__u4fy4~5;$hH9i|b#jU+g0g`-wAtdaaKyHR-ZEr=x* zkfE!+2qicXUe_nHm2g~EBFHMsdy1~2ugNisxN7+z>~4~Ivi4tgY2v`zl-=f&;5FQop1E`I&EUuaC{vO(DnZrU&pkU zLUsLGfE`fcBYal*SpFIGz0+xp`R8viG76EW?o>X8{4R*3Sn(N; zwiSZUyUjwM^{|zSK{CtG{gF4;!J?QkFb4?$)u63X9RsOO zDaQYA*eY#jz>lRmV7ElxU~sCR;d@2tP8r+qioH@AKE50930A>OC^ceN*W1jijQ1JD zD%wd8d%ckl*bn49!n@%VdW+qr5?F@AfO*ZKQ(yl5U~{#hOty61)Tw#I(B%7y$lqIe9YWn9qb_7B^uer^mrLu0F`&Lx_&yZW6>;KHo>n9j` zZGB3zmfVnARFz+_J2`pftUik{&`4s0yv9H~faStp%$Am!Lh~@c*Xnpp%BVAjQ}zH+ zuapS`o79Lmb3=Sbya(E84;Xx5OCCIz@I|K0c6dL)jtVYg2ec0eYeJm2#KX5oxURIC zzP8;GkA0m9b@_&o6W?jP@v&Y#S5%IkJZ)~jA>&32x}o!u-aVeY^@W#5C}TV`Z(O-D z7^uz4j#NatJy=kjpP!WcyxTK-%5`Hf6pHcK{dOzrLc8I)5#i}~ktDZG(_Ai_)9FSa z7Kfmn@q7WlNLKM>FnW%eD*$euvr=1 zBb%Ck`Q5Y6ZjC+#ZA0Hy-ZOChzeN2LQ1=}1QY0w#3AgHk^o2%Z0!J*wWGV znv_-N$qEq-+KDv4bkR;qt;^VeFZyBO0Tt2`*ClU|lQL5i{3+0wI?WIGvsY!_7h08; zN(`HtOY;cKB=&z1#qMul&e#W3q6EzVm=N$%VIIDUjW3ZS`@gdD6_lAH&2PS4s;DaN zS6Q|CmSLOwZQRlKjtehdMx?D<*Gu-2K{-X;^9$>fld5O+Svn;8_+`Wbw?K2x++qc{ zbP?rZUnH&ER#w)vtF5%O9D62OZ9-VHJ9cUxOt$%CXJ)z`_IKf(N@jXOG7-*~-+qsj zC43UO&}Y6<)@eiOI`r?5Jk^Gw48{NFe&+>=jX65I zN%w#I?ZxcdCpNV9!>RMo4{sYhxMcD5J=P6HKg=gu8+z$HD3wO{7&8C2j(K5@(SflV zx*zpC&tltSgr=FVMe3x_Vkv^Au{TEXe)<#fHvubByt4;G`QO|xcB!Oaf~sz`WiNMQtk4J2-M9M;wa-c_Ho$tiZ&$vDhb zV_i5TYIIh=6+MD$K^msx*<4Sz1gkpIu6E(S;|mu}wYGP3gMZAue>! zL)AR`&}{fEKh^)EVdEqmyN9hDC{2)zvk#5F@wGducjU{-L!(}q9@Pwwo+#S3{Ek0-Biw6%TPB{RHTA`kctf|8~UY^9D82Mk3lwy@XK&)7_K^HsQ(g;p177 z!)i`WT9FDxB%H@F3!wEhnuC>*K%7i9*uI(-5R6Y2E0MC$HDVgbk#P;A&1KVUZkxyE zwfJn%Uuno{FJoZ)$+7kv-{;#_;a-E9QseY-fB2gR+1`Oy;4~XfS zkt^*0i|9`(!tArAnfJAXyUZnWUa887cv;J+Py>e*YT^O~2AVLdC)M*rsq<%~dZvOl zZ>F|p-4U16KVqTS?J>=fUJ@K?VE+=VK35iL` z_(~p5OV7y6%7&sLuYHGW@;eq3b}A}{1AJ-Mvhs?`ZdKhQJ$hc->$=|8_vzcO|A2vm z2H$YwO*ap@W$3NLhL5;y`o>=ggfqf5Dv#?^?8Y z$YN`<{DO-FN>35B_HLZ`b_KLl3Wgsit<@_Lu8+?0n_bU9Y{qyME6bd*6I(-=E&zf8d>lKR3Sn-of`jICS`k zIC|{(iNE~y!;k*<@ySm<{p@qZcB(>!%S9SySxI8KXu+1aFjvKR-&49-8Vh^%<s>xx@HP(pep&NHN6{y)C(#ttb>;)pp?&)AKZ#B<_a zu}Zulp1?ls_rZE(wRl?mL2N;z{|44J_lQTuzY&9Ejd)T#0MEit#CNc4cu{X zUX5rJ4WbtFl{>`4V!U`yOb~w-?}|g>pm<+=Aifq8#Zhq>d)#jmlf<`TtvG=>$8j-P zG>LDpZ}e0FOKmYjOcyi7CNWFQ5wpcyF;6TI^TnNFq4=k`ODq>In?*HR zXN6cP&WL{@5>B&-#%j_6YpcJAUr^`M;tND)`5beykHtyxnfOqA3h$a7v2?GBU1C43 zcZyfU0sP)A{v_^?J^Q0r7rWXj*5m3afe+?5Fsq{j%m3f&SK<)?r)fSO6OSwJqn0@2 zM*TEA4@uTRylvbmR;z2pCa5uc8qZ@TKTo`E*ebT;+yY6Lhvy#|tYU`YCNT@YM`W_u zSu4ij{0ir@I3L7066Z5Gt7HF%8?KmYC>CGhTuFJvPh|_#*+Sfp?=DtjqSyni!9?R0 z{4Uf_<68W`PVAA-)7i4dSd9BfXF4bL(0wE7p?;$O8uRe%ZSknO0C`p89`ZCF6Wxp* zM2`Ai%QuLxFp}y*IOBIUa5$uZ1~^Bl6U8ufw|Gg}%fBPl8Df|L@p*A?q*^CllK0Zt zvIo9zd~F^oUZneKwf-KZGyDhdzm0kx!^*c#Y*I@_*fd`BQS(F?*NMtj$_r{N>;Xr= zOarPnPO3N6IbJ8ipK&6*t~eP-;72&d&lr9ufMXQbm94lDer->}tIdhJW9JW1hxVz( zxeV7%H6-PzL9r5EYA<1+Opcv72vt7=&kqpZ=2~d2-xvAD7nx^T+Y@;ts;5P*GD1|S zd%-UQ#1m>x%O}byQLNk}p24}mm?KKf;~1|&)c(YKl&3mW48Z$FzJ3&Z)d4)z0lNnZ z<}1NGLi9qLTZW2>=6zxq@PTP7Sqh0`IQM`jkKtq@*OI`uOQ5YOP; zhaJm1;6$B7G4V0ZNAdonwpHMJY7c6Q*e_kj`0osU^Kb?Tht`vLEp}2nAkX`8Zozo~ zCoD9jh4?*j<~l z$+*Wy*A{Fp22x*jNLa!{qO*1ub60FUj3$B6H#CmejR9JH7qqBu;lZ~#pB_sNtF}au z1dUJ%EbmkC-6IV;kqpd*vqUy@Bsn4%HWBS%0d|eZ7ad`(S13Bcvbk7v7A07VmWr;T z3?pBKs1)5qmFO-aqKD`yu0_nV>kwP)dc^(ei*;^)L{b?j28qGq2E^gIN!%=kh+D)^ zaVug~4HqNCZO~}nE=GybVvN9mj`3hT#)65E`ja8!r$Wk4hn$}Y89y6Ro@6|t(6Nla z3-Wz2B>Ymy_~nrAB;D`E$V21OeUQx$V4V03#--n46#5^?-G?!XJc4lvk{6@alaSd@ zL0bO~di19;Hf_W>vKb@Ob7Bjm_4DEdu@&Rli{g*4q^QA2wM}dnFY|anBLIyCuZh>i zZcz^@|AyEr-V|@a>h4b%>-J-Ocn71~pE1V0i;+zq(GKxwMq}6sj5mM9=<-o)r1=En z%x4&PPKm$s81$w12gcN|F@`l^wE70))me;y-w7H|zsKnLgE)sVi$<&q7^i6@Yk^{3 zf;}cIAz^clZ9cKe)bN91 zR|Y9dl>ex=sE?~(8oL`87*CrjvAe@>OxsL{P2Zabn=e>4L-Ew#`jB;>^p|h$Th*W8F5BGcUQZ=@|1fm_h z^v(7i@CW=${crle4rB%H3p^JL21f)p2LF{%nXo*ued2?ON0LS)M^e&L{vPTZdNK4? zYDVh#)H&g);f3LQ!)wFOg#Q?REqow+H2hikoA57b#x!qQT3SBlY1gL>NgJCsGi^!Q z18I+^J)5>IZBJTb+F#O6r+t^6ntprwpELX!TQmNaY0kVat7F!_?DXvZ*{icZXxAsl zm@_ly_c_ONrQF}-{w=Rd-jcj$@;+=|+5X)Q{tmORDY@ov`CaqJ<}b_tpkvRDZ*=^k zAY3rA;J5gDs^Iy89fiKaXA561Jlx69sY|C@I?eC2wbR8SOHrVxtf;Z*n_{i_&f=q; z1D*3b59qwSq^8F7_f;=e?W)>eb)@RksxwtTcUQZ6x`(@0cYmULO{8;VNTj-lACABaderpzuBVr! zkMJj9DR8%4AZiVE84;z?V}NBX(*VoyeO-g^`f$KXzF*I<2`~%N2?4_)p9qE+rZOyT z8H+nzTlN8#wJZRne6mDkOC{j&mR*1&8BXRjue9{Som~uf^Zj~GxgU}v3;mMdF;01$ z;R%LKoaPMDXQPxF!19)d0V^5SGi(BE2iyw)yWngGDbOEq7sJzh=NG^nq#;;|TIYy* zhWlGi;`#u?CfvyruIMg64W;J^Hz<%NLdZW)q%zEE@#DHz%L9OYfKQ&diQz3R%Wyrc zWj)|XPC1TKPT`bO8BSw3o#70IGa1h29Of{b%WxjUMGO}+T*7cE!(|MYb6M34S1?@3 za1-abnbXuT4z&zl<~zGUfjqIBG29;|{0}f};53a4&){i$Q3{&02d$0))-!AZ>TH84CgYO$8ZtD#SE7)T*`16 z!{rRC8LnWslHuBxM&!SlueWh2+d+qp;$^dd)t-k>~{ls3|?2%y2EkZ49?Fd?o6~ z(_PU#z}=j_p5HypPmeG>%J3M&;|xzQY~qw>@K%vG3(%dnpBH}G{M!zSD*2IoHjsDT2-;C#ByLT@bww>JU~<@+N+!(!w~_owjv zsSKwvoX&6t!F|6VAwG3b39Cl%bQj9U0a@!vr3wVHG1E*Gpq8U6xTiu#@e)X>LEQQt;D z>glE2(@RnJvA8ZpJC%xF&>NMC-qG&>`!KwT;Vms;+!@N(!}xjx!;vix+#km|OyPH@ zGMvV6I>Q+ZXEL0{Im~7_hv8g?^B68-xR~J*hD#YPW4N44tY)}^;Yx;^AUR9XFQ^tZ z3~L#_%y2hjxSvZuz_5YuH!}Q!b3Vgq&hzy}hEc$-pv*qNaSUr1)-r5h*a%n#+Wriv zL86v{wsai=&0$~4Fbh0fhCX!?uoq}v2I)a?0N=TZuZMvr%TNlXoW%F1@cpR_r!kz) za0bJf3}-W(!*DLcc?=gZT+DC@!=((DFWH_7Q9ENil&SSWU;bMkM7%pYFjNvARn;F({S+xvb z<~t31-3V9#{D%X!!~F`DkQKnT5!d7S4oSiaF`MtqVK|rJJccA2D+I~L3PG~50<-gW zfFz|WAf@RJ$-@dk@~}c&VAu>;3GS)^q@G&|?xO1uMx{znk6;!!zml=4WUMN|W4&;v z55t=n4g+Uag1aasVOz;^yAm>uQch(!jp1~LGZ@ZfIGf=dhI1LtW4MUnVuni?E@ilk z;c_mqn&AqDD;W}xRbp=&Dt9x(8pf)YAr_2VwFs> zN~TyPQ>;=D#k#Rn?*$3D4{N~P49@`e1s^^PNHV`K_>it&!3wZ1=1yy{GNjfV1l}V^ z?TB>+Lu$uCg4%JASj2EK!zB!tGF--RGsdPtz`PMFOBZmy1?_bbkVb)9P|8rOG^sBP z=Uy-Z*UPZ-bOVmW^;keRYCVo+{y3KT<9HT24(sA}xKFdtaXbqh$FtCJJPRF%5dp2s zv(Ry<^)6h~EOeZpS?D;Pg^mLq=swLt$AS0e0@5sW9M3|>37UnD6Eq7QhjE0`&@6O3 z&ojsKJaatHGsp8hb3E3E?eLW5nUjF|F+iGaP6GaPO{4Q99-Sxg=sbx>=Sko|x=*9? zBtfI|B;d0PkVfZ8f=1^_D18@J+$41;v(%l8^mI*9cQR5EB&j=v+jk1L?-XFW4{LO4 z@u_@gD&LvLcc$^3X?$lo-9( zE$}>v)qEMl3Shn#HKjY_7}hYXWmwO!fng)VCcqkQ>l$wB8gAl$wB8gA@v`!7Tbq(;`g==c-8gAbta=uRcp zT(1HLy8kdzz6wm}n${q%LL);@X|3@pFd^84JG;53@8+If&$7LqWqUo#_Ij4>^(@=# zQDP->p%JH^WqUo#_Ij4>^^jD(aGzv*J#e6uB-`s*w%4<4uV>j_FG#l63zF^if@FKW zAlY6IDMyfGd%YmpUJofpkYszkAlY6oNVeAtlI`_^WP80J*`+Y_Dh8 zz8|ZSvEU?HkL(8@<^fhl*8|owYyv#MX%29j1Dxgnr#ZlB4hTxqz}()z+}^<4-oV`6 zz}()z+}^<4-oV`6z}()z+}^<4-oV`6z}()z+}^<4-oV`60F36M6q2zG%P! z=6-jW``uygcZa#(9pOBWaGpmv&m)}Y5zg}n=Xr$lJi>V%;XIFUo<}&(Bb?_E&hrT8 zd6e@!%6T5;JdbjoM>)@6Fk8++zInSeu2+?P-B`uY^w zcNa9S#LuVDzGDGteSHcoy8w`8m8a0Y1ZfU=iu=(ioB=!uq-fkk;3yz~S^R z&5llS4?4v?=oH$W?$G-B6xy92t*=j^-4|dir`6(VL94~nf>w)P@_6?(Q}}C8avHS2 zq(S;xkOt{%)*yWix=n*bquFT_ORy%EU`;H+nplE0ff|)~N)oJzYu&^WtcfL96HBlr zmS9bw7^Nf$*2J}LVhPs760C_OSQAUICYE4LEWw&sf;F)OYhnr3#1gEDC0G+nuqKvZ zO)SBhSb{aN1Z!dm*2EI5i6vMQORy%EU`;H+n$UuT6-lrrrhF4iuqKvZO)SBhSb{a7 zeJKq|uqLK|6Vtzm>EFZ>tcfMq8J7QNSpJ_wA3q5#I_aIxfx-ky@AM0|$S>R?zi^9O zKwA_*&rTY@3p^9Mz%#K6JQKUXGqGk))68j_IZZRCY34M|oQA&52z;X%gzqxf;bW(a zcOtkJFp_-%|0BzKVVV&Q31M4>0KSuLL+MW218+;pM24y??493<&QOstuieOn3S$z+0ql^Fq#5P!?47-@Sa143@$TE+j& zblX5L&>P5r4kV;>Z3~ILZ^U-0h>L4Ap-8jQ1nUzcwsAxFbQMuGC^I9rnl+iMRux4U z3@~3c$woynnGk{0=s*#AzDR*?44*a+iVC`Yyx6v@(*p&WO-vv>!OLyvVbe?kYys|oHys+vf|ezoh#gEzBb?fR2mtfP z;DpJgA>bSFl-Z8pZx#?0^`We-4u=sL0}~KTMpf`Giq$wf$`_Tz`(V%bVFD@4CNKrH zBe9^0g5Z;fxN`VI4Vdnf8vJ2*>-4ai5&H!6Kno#3>!GU1-egAsAc55iERYTqLFQJA z69DwEq7um2q7uE#3MICHy)B3Zhw>FljD&D4;R6XN9TQr=!}J76EbxW~4>)Wn(h6cw z1wbVyTvDiT;ABU2Y%ZtKj&?w?W;5(IQ7ZV|>;}oX?G;oO@3*0c8Aw~r3N;9-K@~(n zRv<=Yn5c`!DF=Euyk-G1pw$s&8a!(w-ihIfAEu?*fdXtm#Rlq|9Vm~&;aROt+=e_r zYZ1#>(5+BQ#Yu${J%BziP%VTW-i^@%I9*8(ypMLGmUn;$oTw1m4}Ak9fVglWIv^E} z>Nt=yJS|KPMhsm^rw5qCf=HDpfan~fM{92cixNFlpaGnT1&t^O-lTLFe|R~b7W8oX zbb8pWP6yG$j-F3E5j%`V(9-M#1%Q$r^dP#~t#&-N*<1jihn?s_G+_Qwh)T3cD)A3U zqr$ZnFT=f+2!Vqh6W;WzA0#6|606hcG@0NmPX{57H_QlkXoahi1w~r0*)%qxB?S)F!=jd;XvcRLUjQ8ht~a8algYN=>cNiZVl0iLj_ zlo+WUL_-|ELJxYMnht1!2Q-xEfQzshMS>o7H>5cjlz7wydN@2DGa3SoY_i%7__f)< z@7Tx}OpE_<3 zs6g!C>@6A!Ae5LMn!|~Z2Otmv9)KQ3rUx;QiTaaG(dbn$q7CSS9!49eX1C%cCI|5q zrDMLPOB`SVyg)qxQqkdd5oubYAZ8|dfT198jkX{& zEztv9b`?FWc8ihu+Q#&7yWI{T1_FUZCJYy`1N3kw5IxKmr`?V1#_>y|H1dlbCKIx^ zx)H`6g*ctS!sbSKh^UGGc>&EvY?_YV0Cq9iA$)AA8%W!U9x&5Jkwzru)Vf?CzOvg% zNKrCoZR&}rJ$i!MjXvQ)i73Kh0Uv^d4g|SEhvg=4+Z_(4&uc-(s5cmh26wy*dL%f& zfz%g>9yr>_KpV4$5fwnwP?t1$JRT2 zU^h*d%L)`7=+-U`nrMRphl9GL+3UsL>~xp@kV8Br=;2Ma36h7J!|O&K zRt-aS8+w?{$lK;cmC&sr*^#SP)126r7&-fK8v_;uhQn!dI?P0Shso~*)}V)z`V%a> zkr=7rf2UIz9Lqdw;%@b_b#CB>jbQ8@DED&SL?Q#==5Gcjz0zKSlM6{065~l~=1uHro z9_nT$ryFlO-EK|K5($}S+Z00YgCtI%X|=ioDADb9!g~=U06qLpyT$Erl34LMfk!Z4 z^?@(VZnS~{zu;d;oe*%*^To>`NgH}NOcde8gbILFsY_b?em~IzcU^WTDB6Y|{#3** zH(AjO{2mNlHV;i}P=VNCHiL=nXfmRQ+XHrS_&pw%2MprYf@lZ~3?7>eP3dx4&?+V; z_TB;37!F)cFUaIFBQa8AQ;jR=0rYWDIjFP?Y~uo&Hk&7i5>bT9h9bceS^$#R<8*nD z-tW>hPh!yKccK;^FpvUP2mhiLtwlEJpx|P)P4m$EEeGAK$B^}5F?%0?F@Lln%9k--3e$2ogOaDp}DNY zi!L*$1Qcn!@}WgEv*y9uE{_)(&;gd`K5Qng2K(b+dVt}8Cg_piWqN3Kjp^YIx*S$2 z9P|jd+-`4Df-S)G00YtFPp3y3F)hgk5)Q}!;yivh(TJME>~Vo)n#TiXfZ3P_h~X~& zE~iH@-AVL-R0#S&Q$*dzpifBZhsA>I9cVH)#D>=k^045Z)}{TWg?-J^jINid?<;e9hpYX&Zh+v#?gk);cGKtx#+ z5)!-`-Nm0~<>>^c1b-xC5)4_a&i3 zpU>@axV?}&PEVrdwD~l*59t%!Uav1D3E_TFb011oFvUTC29sohY5zBRI1!)+Oz!si zz&44Ai9R5Pq1}Uh>?}Y)KR}PfEEf#kY!1IWF#sNO_(8tQ=z&h{N<@wPZnxhLdT5D$ zzsK)I&fXM|3H0zgs3|>g3Pay?Tar9(py;xDAYaf=EJ%#h*pXbPFj$^tngz^-gZc*E zcVhtZ08NL(pNtayem8LSLGC!cNp6>o3I{zBK@Wc@*^vllwEFFuhbF_|UyMpwUIa)X zSxLeY^u!PzKQu74$7Mm5U@sC;wxpyaKM=!R{6U1rQ-U5z*%}!DLMkK$!DG$ob$Q_;hyLWYBzrwT5#uuCi`(V3 zA~8}UG^d^;q!7zA3-xjjk|HtY;2?Ngkgs zkecF1a$`&jfPoau%$OeTY+_oH4TLaA(uN*xbT_ZdiY!4+5>aqJPew2Sx{E)X6%C>v zpht3!PLH5BIf3XABzh2%`eC!7Q)_54ALtPXqE+0SdH@0a06kK2-2(F=NXe80(9{*gLi8$nq=4cy|8>SClE4T(Ix*a}Gk_U3bip^pJes3@V8PI_?Vwq-Tv5BNeO!R<+0-B&l zY65B$^!i`XyFwFS|kR80B&eyjvMxnNp7Uq~UmPmAT) z;Q>>}=z*PUi5{U)CKAPHiB2=W7C?>hWXw0tMTXjkQrB21nYLhOG1 ZpvJ#@o~VO Generated from the API surface, consumer audit, and `resources/migration/destination-decisions.php`. + +| Symbol | Kind | Disposition | Availability | Target | Status | Uses | Parity | +|---|---|---|---|---|---|---:|---| +| `Xoops\ModuleTools\Admin\Export` | type | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Admin\Export::__construct()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Admin\Export::render()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Admin\Export::setNotDisplayFields()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Admin\Export::setOuptutMethods()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::$align` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::$caption` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::$key` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::$parameters` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::$sortable` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::$valueMethod` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::$width` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectColumn::__construct()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::$handler` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::__construct()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::handleObjectDeletion()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::handleObjectDeletionFromUserSide()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::postDataToObject()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::storeFromDefaultForm()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::storeSmartObject()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectController::storeSmartObjectD()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectRow` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectRow::$class` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectRow::$header` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectRow::$key` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectRow::$valueMethod` | property | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectRow::__construct()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::__construct()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::addActionButton()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::addColumn()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::addCustomAction()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::addFilter()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::addIntroButton()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::addWithSelectedActions()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::disableColumnsSorting()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::fetch()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::hideFilterAndLimit()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::isForUserSide()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::render()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::setDefaultFilter()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::setDefaultFilter2()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::setDefaultOrder()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::setDefaultSort()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::setObjects()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\ObjectTable::setTableId()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\SingleView` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\SingleView::__construct()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\SingleView::addRow()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\SingleView::fetch()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\SingleView::render()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\Table` | type | retain-compat (legacy-alias) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Admin\TreeTable` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Admin\Utility` | type | retain-compat (legacy-collapsible-adapter) | not-applicable | `—` | missing | 1 | `tests/Admin/UtilityTest.php` | +| `Xoops\ModuleTools\Admin\Utility::closeCollapsable()` | method | retain-compat (legacy-collapsible-adapter) | not-applicable | `—` | missing | 1 | `tests/Admin/UtilityTest.php` | +| `Xoops\ModuleTools\Admin\Utility::closeCollapsible()` | method | retain-compat (legacy-collapsible-adapter) | not-applicable | `—` | missing | 0 | `tests/Admin/UtilityTest.php` | +| `Xoops\ModuleTools\Admin\Utility::getCollapsableBar()` | method | retain-compat (legacy-collapsible-adapter) | not-applicable | `—` | missing | 1 | `tests/Admin/UtilityTest.php` | +| `Xoops\ModuleTools\Admin\Utility::openCollapsible()` | method | retain-compat (legacy-collapsible-adapter) | not-applicable | `—` | missing | 0 | `tests/Admin/UtilityTest.php` | +| `Xoops\ModuleTools\Bootstrap` | type | retain-compat (reassess) | not-applicable | `—` | not-applicable | 18 | `missing` | +| `Xoops\ModuleTools\Bootstrap::API_VERSION` | constant | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::MIN_MODULE_VERSION` | constant | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::MODULE_DIRNAME` | constant | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::PACKAGE` | constant | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::VERSION` | constant | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::apiVersion()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::assertRuntime()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::checkRuntime()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Bootstrap::statusMessage()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin` | type | retain-compat | not-applicable | `—` | not-applicable | 55 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::$db` | property | retain-compat | not-applicable | `—` | not-applicable | 55 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::$helper` | property | retain-compat | not-applicable | `—` | not-applicable | 55 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::$moduleDirName` | property | retain-compat | not-applicable | `—` | not-applicable | 55 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::$moduleDirNameUpper` | property | retain-compat | not-applicable | `—` | not-applicable | 55 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::__construct()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::cloneBlock()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::deleteBlock()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::editBlock()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::isBlockCloned()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::listBlocks()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::orderBlock()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::render()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::setOrder()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Blocksadmin::updateBlock()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Breadcrumb` | type | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 23 | `missing` | +| `Xoops\ModuleTools\Common\Breadcrumb::$dirname` | property | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 23 | `missing` | +| `Xoops\ModuleTools\Common\Breadcrumb::__construct()` | method | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Breadcrumb::addLink()` | method | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Breadcrumb::render()` | method | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Cloner` | type | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Cloner::clone()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Cloner::createLogo()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Cloner::handleAdminRequest()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Cloner::isValidDirname()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Configurator` | type | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$copyBlankFiles` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$copyTestFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$icons` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$modCopyright` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$moduleStats` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$name` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$oldFiles` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$oldFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$paths` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$renameColumns` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$renameTables` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$templateFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::$uploadFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 498 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::__construct()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::baseDir()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::config()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::forModule()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 9 | `missing` | +| `Xoops\ModuleTools\Common\Configurator::getPath()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Confirm` | type | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 93 | `missing` | +| `Xoops\ModuleTools\Common\Confirm::__construct()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Confirm::forModule()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Confirm::getFormConfirm()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Db` | type | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 6 | `missing` | +| `Xoops\ModuleTools\Common\Db::blockAddCatSelect()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Db::cloneRecord()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 1 | `missing` | +| `Xoops\ModuleTools\Common\Db::enumerate()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Db::fieldExists()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 10 | `missing` | +| `Xoops\ModuleTools\Common\Db::queryAndCheck()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Db::queryFAndCheck()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Db::tableExists()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DbInterface` | type | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DbInterface::blockAddCatSelect()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DbInterface::cloneRecord()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DbInterface::enumerate()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DbInterface::fieldExists()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DbInterface::queryAndCheck()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DirectoryChecker` | type | retain-compat (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 13 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\DirectoryChecker::createDirectory()` | method | retain-compat (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\DirectoryChecker::dirExists()` | method | retain-compat (method-specific-residue) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\DirectoryChecker::getDirectoryStatus()` | method | retain-compat (method-specific-residue) | not-applicable | `—` | not-applicable | 17 | `missing` | +| `Xoops\ModuleTools\Common\DirectoryChecker::handleRequest()` | method | retain-compat (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\DirectoryChecker::setDirectoryPermissions()` | method | retain-compat (method-specific-residue) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\FileChecker` | type | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 1 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FileChecker::compareFiles()` | method | retain-compat (method-specific-residue) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\FileChecker::copyFile()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FileChecker::fileExists()` | method | retain-compat (method-specific-residue) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\FileChecker::getFileStatus()` | method | retain-compat (method-specific-residue) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\FileChecker::setFilePermissions()` | method | retain-compat (method-specific-residue) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\FilesManagement` | type | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 25 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::copyFile()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::createFolder()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::deleteDirectory()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::rcopy()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::recurseCopy()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::rmove()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::rrmdir()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 1 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\FilesManagement::xcopy()` | method | promote (delegating-shim) | delegate-now | `Xoops\Helpers\Utility\Filesystem` | stable | 0 | `tests/Migration/FilesystemDelegationTest.php` | +| `Xoops\ModuleTools\Common\Highlighter` | type | retain-compat | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\Highlighter::__construct()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Highlighter::highlight()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ImageResizerInterface` | type | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ImageResizerInterface::resize()` | method | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\LetterChoice` | type | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 3 | `missing` | +| `Xoops\ModuleTools\Common\LetterChoice::$helper` | property | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 3 | `missing` | +| `Xoops\ModuleTools\Common\LetterChoice::$modHelper` | property | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 3 | `missing` | +| `Xoops\ModuleTools\Common\LetterChoice::__construct()` | method | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\LetterChoice::render()` | method | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest` | type | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$allowedMime` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$file` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$maxBytes` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$maxHeight` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$maxWidth` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$namePrefix` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$quality` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$targetDir` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$thumbnailDir` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::$thumbnailWidths` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Media\MediaUploadRequest::__construct()` | method | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Migrate` | type | promote (delegating-shim) | delegate-now | `Xmf\Database\Migrate` | stable | 82 | `tests/Migration/MigrateDelegationTest.php` | +| `Xoops\ModuleTools\Common\Migrate::__construct()` | method | promote (delegating-shim) | delegate-now | `Xmf\Database\Migrate` | stable | 0 | `tests/Migration/MigrateDelegationTest.php` | +| `Xoops\ModuleTools\Common\ModuleConfig` | type | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$copyBlankFiles` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$copyTestFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$modCopyright` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$moduleStats` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$name` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$oldFiles` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$oldFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$paths` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$renameColumns` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$renameTables` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$templateFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::$uploadFolders` | property | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::__construct()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleConfig::fromObject()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback` | type | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 45 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::$content` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 45 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::$email` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 45 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::$name` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 45 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::$site` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 45 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::$type` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 45 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::__construct()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::getFormFeedback()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleFeedback::getInstance()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleStats` | type | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ModuleStats::getModuleStats()` | method | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ObjectTree` | type | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 17 | `missing` | +| `Xoops\ModuleTools\Common\ObjectTree::makeSelBox()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ObjectTree::makeSelBoxOptionsArray()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Output` | type | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\Output::getEditor()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Output::metaDescription()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\Output::metaKeywords()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\Output::selectSorting()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState` | type | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::__construct()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::band()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::bandEnd()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::bandStart()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::currentPage()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::hasNext()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::hasPrevious()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::isFirst()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::isLast()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::limit()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::limitClause()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::offset()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::pageCount()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::pages()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::rangeEnd()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::rangeStart()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationState::total()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface` | type | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::band()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::bandEnd()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::bandStart()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::currentPage()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::hasNext()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::hasPrevious()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::isFirst()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::isLast()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::limit()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::limitClause()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::offset()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::pageCount()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::pages()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::rangeEnd()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::rangeStart()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\PaginationStateInterface::total()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator` | type | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$current` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$glue` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$limit` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$orderSql` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$pCurrent` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$pTotal` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$pageLimit` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$queryStr` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$toPage` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$total` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$urlOther` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::$urlPage` | property | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 2 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::__construct()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::init()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::makeBar()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::makeBootStrapBar()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::processQuery()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::setToPage()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::setUrlOther()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Paginator::sqlQuery()` | method | migrate-to-target (parity-blocked) | delegate-now | `Xmf\Pagination\PaginatedResult` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest` | type | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$allowUpscale` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$fit` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$maxHeight` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$maxWidth` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$outputMime` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$quality` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$sourcePath` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::$targetPath` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 4 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::FIT_COVER` | constant | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::FIT_INSIDE` | constant | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::FIT_STRETCH` | constant | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeRequest::__construct()` | method | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult` | type | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::$error` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::$height` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::$ok` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::$targetPath` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::$width` | property | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::__construct()` | method | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::failure()` | method | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ResizeResult::success()` | method | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\Resizer` | type | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 6 | `missing` | +| `Xoops\ModuleTools\Common\Resizer::resize()` | method | blocked-on-upstream (provisional-adapter) | migrate-on-4.0 | `Xmf\Media\Storage` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\ServerStats` | type | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 25 | `missing` | +| `Xoops\ModuleTools\Common\ServerStats::getServerStats()` | method | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SocialBookmarks` | type | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\SocialBookmarks::DEFAULT_NETWORKS` | constant | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\SocialBookmarks::render()` | method | blocked-on-upstream (find-exact-presentation-owner) | not-applicable | `—` | missing | 0 | `missing` | +| `Xoops\ModuleTools\Common\StandardConfig` | type | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\StandardConfig::append()` | method | retain-compat (reassess-ownership) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility` | type | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 167 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::blockAddCatSelect()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::cloneRecord()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::enumerate()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::fieldExists()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::getEditor()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::getInstance()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::metaDescription()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::metaKeywords()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::prepareFolder()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::queryAndCheck()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::queryFAndCheck()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::selectSorting()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::tableExists()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\SysUtility::truncateHtml()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons` | type | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 119 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons::hideButtons()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 119 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons::loadButtonConfig()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 119 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons::renderHtml()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons::setSampleButtonConfig()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons::showButtons()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 119 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample` | type | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 5 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::$language` | property | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 5 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::$modHelper` | property | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 5 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::$moduleDirName` | property | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 5 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::$moduleDirNameUpper` | property | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 5 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::$xoopsConfig` | property | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 5 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::__construct()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::clearData()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::exportSchema()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::loadData()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | +| `Xoops\ModuleTools\Common\TestdataSample::saveData()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | +| `Xoops\ModuleTools\Common\Text` | type | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\Text::truncateHtml()` | method | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Common\TextInterface` | type | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\TextInterface::truncateHtml()` | method | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\UpdateChecker` | type | retain-compat (reassess) | not-applicable | `—` | not-applicable | 3 | `missing` | +| `Xoops\ModuleTools\Common\UpdateChecker::checkVerModule()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 3 | `missing` | +| `Xoops\ModuleTools\Common\VersionChecks` | type | retain-compat | not-applicable | `—` | not-applicable | 24 | `missing` | +| `Xoops\ModuleTools\Common\VersionChecks::checkVerPhp()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\VersionChecks::checkVerXoops()` | method | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Constants` | type | retain-compat | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Constants::DISALLOW` | constant | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Constants::REDIRECT_DELAY_LONG` | constant | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Constants::REDIRECT_DELAY_MEDIUM` | constant | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Constants::REDIRECT_DELAY_NONE` | constant | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Constants::REDIRECT_DELAY_SHORT` | constant | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Form\ObjectFormBuilder` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Form\ObjectFormBuilder::build()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Module\ConsumerRuntime` | type | retain-compat (reassess) | not-applicable | `—` | not-applicable | 100 | `missing` | +| `Xoops\ModuleTools\Module\ConsumerRuntime::assertReady()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\ConsumerRuntime::dependencyError()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 99 | `missing` | +| `Xoops\ModuleTools\Module\ConsumerRuntime::guard()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\ConsumerRuntime::isReady()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\Dependency` | type | retain-compat (reassess) | not-applicable | `—` | not-applicable | 1 | `missing` | +| `Xoops\ModuleTools\Module\Dependency::checkModule()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\Dependency::requireModule()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\Dependency::statusMessage()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\Installer` | type | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 18 | `missing` | +| `Xoops\ModuleTools\Module\Installer::copyBlankFiles()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 8 | `missing` | +| `Xoops\ModuleTools\Module\Installer::copyTestFolders()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\Installer::createUploadFolders()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 12 | `missing` | +| `Xoops\ModuleTools\Module\Installer::dropModuleTables()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Module\Installer::install()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 11 | `missing` | +| `Xoops\ModuleTools\Module\Installer::prepare()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 11 | `missing` | +| `Xoops\ModuleTools\Module\Installer::purgeHtmlTemplates()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 8 | `missing` | +| `Xoops\ModuleTools\Module\Installer::removeOldAssets()` | method | retain-compat (then-migrate) | not-applicable | `—` | not-applicable | 5 | `missing` | +| `Xoops\ModuleTools\Module\ModuleContext` | type | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 2 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::adminPath()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::adminUrl()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::config()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::defineConstants()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::for()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::fromHelper()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 1 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::imagesPath()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::imagesUrl()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::path()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::uploadPath()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::uploadUrl()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\ModuleContext::url()` | method | promote (deprecated-facade) | delegate-now | `Xoops\Helpers\Service\Path` | stable | 0 | `tests/Migration/ModuleContextDelegationTest.php` | +| `Xoops\ModuleTools\Module\NamespaceAutoloader` | type | retain-compat (transitional-autoloader) | not-applicable | `—` | not-applicable | 361 | `tests/Module/NamespaceAutoloaderTest.php` | +| `Xoops\ModuleTools\Module\NamespaceAutoloader::register()` | method | retain-compat (transitional-autoloader) | not-applicable | `—` | not-applicable | 0 | `tests/Module/NamespaceAutoloaderTest.php` | +| `Xoops\ModuleTools\Object\DynamicObject` | type | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::$_image_path` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::$_image_url` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::$controls` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::$handler` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::$seoEnabled` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::$summaryField` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::$titleField` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::accessGranted()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::addFormSection()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::closeSection()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::displayFieldOnSingleView()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::getControl()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::getForm()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::getImageDir()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::getUploadDir()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::getValueFor()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::getVarInfo()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::hasError()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::hideFieldFromForm()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::hideFieldFromSingleView()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::id()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::initCommonVar()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::initNonPersistableVar()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::initVar()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::makeFieldReadOnly()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::setControl()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::setErrors()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::setFieldAsRequired()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::setFieldForSorting()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::setImageDir()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::setVarInfo()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::showFieldOnForm()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::summary()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\DynamicObject::title()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\SeoObject` | type | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 3 | `missing` | +| `Xoops\ModuleTools\Object\SeoObject::initSeoVars()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\SeoObject::meta_description()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Object\SeoObject::meta_keywords()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Permission\ItemPermission` | type | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 3 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\ItemPermission::__construct()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\ItemPermission::delete()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\ItemPermission::forModule()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 3 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\ItemPermission::grantedGroups()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\ItemPermission::grantedItems()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\ItemPermission::isGranted()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\ItemPermission::replace()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\PermissionGatewayInterface` | type | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\PermissionGatewayInterface::add()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\PermissionGatewayInterface::checkRight()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\PermissionGatewayInterface::delete()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\PermissionGatewayInterface::groupIds()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\PermissionGatewayInterface::itemIds()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway` | type | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway::__construct()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway::add()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway::checkRight()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway::delete()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway::fromCore()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway::groupIds()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Permission\XoopsPermissionGateway::itemIds()` | method | blocked-on-upstream (provisional-adapter) | delegate-now | `Xmf\Permissions\CoreGroupPermissionGateway` | provisional | 0 | `tests/Permission/ItemPermissionTest.php` | +| `Xoops\ModuleTools\Persistence\PersistableHandler` | type | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler2` | type | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 3 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler2::__construct()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_allowedMimeTypes` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_itemname` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_maxFileSize` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_maxHeight` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_maxWidth` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_moduleName` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_modulePath` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_moduleUrl` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_page` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_uploadPath` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$_uploadUrl` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$permissionsArray` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::$summaryName` | property | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 12 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::__construct()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::addPermission()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::create()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::getImagePath()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::getImageUrl()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::getModuleConfig()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::getModuleInfo()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::getObjects2()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::getPermissions()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::setGrantedObjectsCriteria()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Persistence\PersistableHandler::setUploaderConfig()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Utility` | type | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 5 | `missing` | diff --git a/resources/gate/promotion-backlog.md b/resources/gate/promotion-backlog.md new file mode 100644 index 0000000..a74b039 --- /dev/null +++ b/resources/gate/promotion-backlog.md @@ -0,0 +1,328 @@ +# ModuleTools promotion backlog + +> Generated from unresolved type-level destination records. + +## Xoops\ModuleTools\Admin\Export + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Admin\ObjectColumn + +- Target: `xoops/xmf` / `Xmf\Presentation\DataTable` (experimental). +- Blocker: Keep frozen until the XMF presentation contract is stable. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Admin\ObjectController + +- Target: `xoops/xmf` / `Xmf\Presentation\DataTable` (experimental). +- Blocker: Keep frozen until the XMF presentation contract is stable. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Admin\ObjectRow + +- Target: `xoops/xmf` / `Xmf\Presentation\DataTable` (experimental). +- Blocker: Keep frozen until the XMF presentation contract is stable. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Admin\ObjectTable + +- Target: `xoops/xmf` / `Xmf\Presentation\DataTable` (experimental). +- Blocker: Keep frozen until the XMF presentation contract is stable. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Admin\SingleView + +- Target: `xoops/xmf` / `Xmf\Presentation\DataTable` (experimental). +- Blocker: Keep frozen until the XMF presentation contract is stable. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Admin\Table + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Admin\TreeTable + +- Target: `xoops/xmf` / `Xmf\Presentation\DataTable` (experimental). +- Blocker: Keep frozen until the XMF presentation contract is stable. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Bootstrap + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Blocksadmin + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Breadcrumb + +- Target: `xoops/smartyextensions` / `none` (missing). +- Blocker: Installed presentation helpers do not expose an exact equivalent API. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Cloner + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Configurator + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Confirm + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Db + +- Target: `xoops/xmf` / `Xmf\Repository\RepositoryInterface` (stable). +- Blocker: Static database helpers remain compatibility-only; repositories own new persistence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\DbInterface + +- Target: `xoops/xmf` / `Xmf\Repository\RepositoryInterface` (stable). +- Blocker: Static database helpers remain compatibility-only; repositories own new persistence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Highlighter + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ImageResizerInterface + +- Target: `xoops/xmf` / `Xmf\Media\Storage` (missing). +- Blocker: No proven stable XMF media/storage contract currently matches this surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\LetterChoice + +- Target: `xoops/smartyextensions` / `none` (missing). +- Blocker: Installed presentation helpers do not expose an exact equivalent API. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Media\MediaUploadRequest + +- Target: `xoops/xmf` / `Xmf\Media\Storage` (missing). +- Blocker: No proven stable XMF media/storage contract currently matches this surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ModuleConfig + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ModuleFeedback + +- Target: `xoops/xmf` / `Xmf\Bridge\HandlerToRepositoryBridge` (stable). +- Blocker: XOOPS 2.x object compatibility; consumers use the 4.0 bridge sequence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ModuleStats + +- Target: `none` / `none` (not-applicable). +- Blocker: Retained until an exact stable owner and parity corpus exist. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ObjectTree + +- Target: `xoops/xmf` / `Xmf\Bridge\HandlerToRepositoryBridge` (stable). +- Blocker: XOOPS 2.x object compatibility; consumers use the 4.0 bridge sequence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Output + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\PaginationState + +- Target: `xoops/xmf` / `Xmf\Pagination\PaginatedResult` (stable). +- Blocker: The parity corpus documents non-equivalence (zero pages, bands, SQL and rendering); no shim yet. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\PaginationStateInterface + +- Target: `xoops/xmf` / `Xmf\Pagination\PaginatedResult` (stable). +- Blocker: The parity corpus documents non-equivalence (zero pages, bands, SQL and rendering); no shim yet. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Paginator + +- Target: `xoops/xmf` / `Xmf\Pagination\PaginatedResult` (stable). +- Blocker: The parity corpus documents non-equivalence (zero pages, bands, SQL and rendering); no shim yet. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ResizeRequest + +- Target: `xoops/xmf` / `Xmf\Media\Storage` (missing). +- Blocker: No proven stable XMF media/storage contract currently matches this surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ResizeResult + +- Target: `xoops/xmf` / `Xmf\Media\Storage` (missing). +- Blocker: No proven stable XMF media/storage contract currently matches this surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Resizer + +- Target: `xoops/xmf` / `Xmf\Media\Storage` (missing). +- Blocker: No proven stable XMF media/storage contract currently matches this surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\ServerStats + +- Target: `none` / `none` (not-applicable). +- Blocker: Retained until an exact stable owner and parity corpus exist. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\SocialBookmarks + +- Target: `xoops/smartyextensions` / `none` (missing). +- Blocker: Installed presentation helpers do not expose an exact equivalent API. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\StandardConfig + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\SysUtility + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\TestdataButtons + +- Target: `xoops/xmf` / `Xmf\Module\Testdata` (provisional). +- Blocker: The target is absent from XMF 1.5 and must be proven in the PHP 8.4 tree. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\TestdataSample + +- Target: `xoops/xmf` / `Xmf\Module\Testdata` (provisional). +- Blocker: The target is absent from XMF 1.5 and must be proven in the PHP 8.4 tree. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\Text + +- Target: `none` / `none` (not-applicable). +- Blocker: Retained until an exact stable owner and parity corpus exist. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\TextInterface + +- Target: `none` / `none` (not-applicable). +- Blocker: Retained until an exact stable owner and parity corpus exist. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\UpdateChecker + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Common\VersionChecks + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Constants + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Form\ObjectFormBuilder + +- Target: `xoops/xmf` / `Xmf\Presentation\DataTable` (experimental). +- Blocker: Keep frozen until the XMF presentation contract is stable. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Module\ConsumerRuntime + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Module\Dependency + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Module\Installer + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Object\DynamicObject + +- Target: `xoops/xmf` / `Xmf\Bridge\HandlerToRepositoryBridge` (stable). +- Blocker: XOOPS 2.x object compatibility; consumers use the 4.0 bridge sequence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Object\SeoObject + +- Target: `xoops/xmf` / `Xmf\Bridge\HandlerToRepositoryBridge` (stable). +- Blocker: XOOPS 2.x object compatibility; consumers use the 4.0 bridge sequence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Permission\ItemPermission + +- Target: `xoops/xmf` / `Xmf\Permissions\CoreGroupPermissionGateway` (provisional). +- Blocker: The local adapter is retained until upstream BC and write semantics are proven. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Permission\PermissionGatewayInterface + +- Target: `xoops/xmf` / `Xmf\Permissions\CoreGroupPermissionGateway` (provisional). +- Blocker: The local adapter is retained until upstream BC and write semantics are proven. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Permission\XoopsPermissionGateway + +- Target: `xoops/xmf` / `Xmf\Permissions\CoreGroupPermissionGateway` (provisional). +- Blocker: The local adapter is retained until upstream BC and write semantics are proven. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Persistence\PersistableHandler + +- Target: `xoops/xmf` / `Xmf\Bridge\HandlerToRepositoryBridge` (stable). +- Blocker: XOOPS 2.x object compatibility; consumers use the 4.0 bridge sequence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Persistence\PersistableHandler2 + +- Target: `xoops/xmf` / `Xmf\Bridge\HandlerToRepositoryBridge` (stable). +- Blocker: XOOPS 2.x object compatibility; consumers use the 4.0 bridge sequence. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + +## Xoops\ModuleTools\Utility + +- Target: `none` / `none` (not-applicable). +- Blocker: Frozen XOOPS 2.x compatibility surface. +- Required evidence: behavioral parity corpus plus an explicit stable owner. + diff --git a/resources/language/english/common.php b/resources/language/english/common.php new file mode 100644 index 0000000..2e5c344 --- /dev/null +++ b/resources/language/english/common.php @@ -0,0 +1,232 @@ +Disabled (No thumbnails available)"); +$define('_CO_MTOOLS_GDON', "Enabled (Thumbsnails available)"); +$define('_CO_MTOOLS_IMAGEINFO', 'Server status'); +$define('_CO_MTOOLS_MAXPOSTSIZE', 'Max post size permitted (post_max_size directive in php.ini): '); +$define('_CO_MTOOLS_MAXUPLOADSIZE', 'Max upload size permitted (upload_max_filesize directive in php.ini): '); +$define('_CO_MTOOLS_MEMORYLIMIT', 'Memory limit (memory_limit directive in php.ini): '); +$define('_CO_MTOOLS_METAVERSION', "Downloads meta version: "); +$define('_CO_MTOOLS_OFF', "OFF"); +$define('_CO_MTOOLS_ON', "ON"); +$define('_CO_MTOOLS_SERVERPATH', 'Server path to XOOPS root: '); +$define('_CO_MTOOLS_SERVERUPLOADSTATUS', 'Server uploads status: '); +$define('_CO_MTOOLS_SPHPINI', "Information taken from PHP ini file:"); +$define('_CO_MTOOLS_UPLOADPATHDSC', 'Note. Upload path *MUST* contain the full server path of your upload folder.'); + +$define('_CO_MTOOLS_PRINT', "Print"); +$define('_CO_MTOOLS_PDF', "Create PDF"); + +$define('_CO_MTOOLS_UPGRADEFAILED0', "Update failed - couldn't rename field '%s'"); +$define('_CO_MTOOLS_UPGRADEFAILED1', "Update failed - couldn't add new fields"); +$define('_CO_MTOOLS_UPGRADEFAILED2', "Update failed - couldn't rename table '%s'"); +$define('_CO_MTOOLS_ERROR_COLUMN', 'Could not create column in database : %s'); +$define('_CO_MTOOLS_ERROR_BAD_XOOPS', 'This module requires XOOPS %s+ (%s installed)'); +$define('_CO_MTOOLS_ERROR_BAD_PHP', 'This module requires PHP version %s+ (%s installed)'); +$define('_CO_MTOOLS_ERROR_TAG_REMOVAL', 'Could not remove tags from Tag Module'); + +$define('_CO_MTOOLS_' . 'FOLDERS_DELETED_OK', 'Upload Folders have been deleted'); + +// Error Msgs +$define('_CO_MTOOLS_' . 'ERROR_BAD_DEL_PATH', 'Could not delete %s directory'); +$define('_CO_MTOOLS_' . 'ERROR_BAD_REMOVE', 'Could not delete %s'); +$define('_CO_MTOOLS_' . 'ERROR_NO_PLUGIN', 'Could not load plugin'); + +//Help +$define('_CO_MTOOLS_' . 'DIRNAME', 'mtools'); +$define('_CO_MTOOLS_' . 'HELP_HEADER', __DIR__ . '/help/helpheader.tpl'); +$define('_CO_MTOOLS_' . 'BACK_2_ADMIN', 'Back to Administration of '); +$define('_CO_MTOOLS_' . 'OVERVIEW', 'Overview'); + +//\define('_CO_MTOOLS_HELP_DIR', __DIR__); + +//help multipage +$define('_CO_MTOOLS_' . 'DISCLAIMER', 'Disclaimer'); +$define('_CO_MTOOLS_' . 'LICENSE', 'License'); +$define('_CO_MTOOLS_' . 'SUPPORT', 'Support'); + +//Sample Data +$define('_CO_MTOOLS_' . 'LOAD_SAMPLEDATA', 'Import Sample Data (will delete ALL current data)'); +$define('_CO_MTOOLS_' . 'LOAD_SAMPLEDATA_CONFIRM', 'Are you sure to Import Sample Data? (It will delete ALL current data)'); +$define('_CO_MTOOLS_' . 'LOAD_SAMPLEDATA_SUCCESS', 'Sample Date imported successfully'); +$define('_CO_MTOOLS_' . 'SAVE_SAMPLEDATA', 'Export Tables to YAML'); +$define('_CO_MTOOLS_' . 'SAVE_SAMPLEDATA_SUCCESS', 'Export Tables to YAML successfully'); +$define('_CO_MTOOLS_' . 'CLEAR_SAMPLEDATA', 'Clear Sample Data'); +$define('_CO_MTOOLS_' . 'CLEAR_SAMPLEDATA_OK', 'The Sample Data has been cleared'); +$define('_CO_MTOOLS_' . 'CLEAR_SAMPLEDATA_CONFIRM', 'Are you sure to Clear Sample Data? (It will delete ALL current data)'); +$define('_CO_MTOOLS_' . 'EXPORT_SCHEMA', 'Export DB Schema to YAML'); +$define('_CO_MTOOLS_' . 'EXPORT_SCHEMA_SUCCESS', 'Export DB Schema to YAML was a success'); +$define('_CO_MTOOLS_' . 'EXPORT_SCHEMA_ERROR', 'ERROR: Export of DB Schema to YAML failed'); +$define('_CO_MTOOLS_' . 'SHOW_SAMPLE_BUTTON', 'Show Sample Button?'); +$define('_CO_MTOOLS_' . 'SHOW_SAMPLE_BUTTON_DESC', 'If yes, the "Add Sample Data" button will be visible to the Admin. It is Yes as a default for first installation.'); +$define('_CO_MTOOLS_' . 'HIDE_SAMPLEDATA_BUTTONS', 'Hide the Import buttons)'); +$define('_CO_MTOOLS_' . 'SHOW_SAMPLEDATA_BUTTONS', 'Show the Import buttons)'); + +$define('_CO_MTOOLS_' . 'LOAD_SAMPLEDATA_FAILURE', 'Sample Date import failed'); + +$define('_CO_MTOOLS_' . 'CONFIRM', 'Confirm'); + +//letter choice +$define('_CO_MTOOLS_' . 'BROWSETOTOPIC', "Browse items alphabetically"); +$define('_CO_MTOOLS_' . 'OTHER', 'Other'); +$define('_CO_MTOOLS_' . 'ALL', 'All'); + +// block defines +$define('_CO_MTOOLS_' . 'ACCESSRIGHTS', 'Access Rights'); +$define('_CO_MTOOLS_' . 'ACTION', 'Action'); +$define('_CO_MTOOLS_' . 'ACTIVERIGHTS', 'Active Rights'); +$define('_CO_MTOOLS_' . 'BADMIN', 'Block Administration'); +$define('_CO_MTOOLS_' . 'BLKDESC', 'Description'); +$define('_CO_MTOOLS_' . 'CBCENTER', 'Center Middle'); +$define('_CO_MTOOLS_' . 'CBLEFT', 'Center Left'); +$define('_CO_MTOOLS_' . 'CBRIGHT', 'Center Right'); +$define('_CO_MTOOLS_' . 'SBLEFT', 'Left'); +$define('_CO_MTOOLS_' . 'SBRIGHT', 'Right'); +$define('_CO_MTOOLS_' . 'SIDE', 'Alignment'); +$define('_CO_MTOOLS_' . 'TITLE', 'Title'); +$define('_CO_MTOOLS_' . 'VISIBLE', 'Visible'); +$define('_CO_MTOOLS_' . 'VISIBLEIN', 'Visible In'); +$define('_CO_MTOOLS_' . 'WEIGHT', 'Weight'); + +$define('_CO_MTOOLS_' . 'PERMISSIONS', 'Permissions'); +$define('_CO_MTOOLS_' . 'BLOCKS', 'Blocks Admin'); +$define('_CO_MTOOLS_' . 'BLOCKS_DESC', 'Blocks/Group Admin'); + +$define('_CO_MTOOLS_' . 'BLOCKS_MANAGMENT', 'Manage'); +$define('_CO_MTOOLS_' . 'BLOCKS_ADDBLOCK', 'Add a new block'); +$define('_CO_MTOOLS_' . 'BLOCKS_EDITBLOCK', 'Edit a block'); +$define('_CO_MTOOLS_' . 'BLOCKS_CLONEBLOCK', 'Clone a block'); + +//myblocksadmin +$define('_CO_MTOOLS_' . 'AGDS', 'Admin Groups'); +$define('_CO_MTOOLS_' . 'BCACHETIME', 'Cache Time'); +$define('_CO_MTOOLS_' . 'BLOCKS_ADMIN', 'Blocks Admin'); +$define('_CO_MTOOLS_' . 'UPDATE_SUCCESS', 'Update successful'); + +//Template Admin +$define('_CO_MTOOLS_' . 'TPLSETS', 'Template Management'); +$define('_CO_MTOOLS_' . 'GENERATE', 'Generate'); +$define('_CO_MTOOLS_' . 'FILENAME', 'File Name'); + +//Menu +$define('_CO_MTOOLS_' . 'ADMENU_MIGRATE', 'Migrate'); +$define('_CO_MTOOLS_' . 'FOLDER_YES', 'Folder "%s" exist'); +$define('_CO_MTOOLS_' . 'FOLDER_NO', 'Folder "%s" does not exist. Create the specified folder with CHMOD 777.'); +$define('_CO_MTOOLS_' . 'SHOW_DEV_TOOLS', 'Show Development Tools Button?'); +$define('_CO_MTOOLS_' . 'SHOW_DEV_TOOLS_DESC', 'If yes, the "Migrate" Tab and other Development tools will be visible to the Admin.'); +$define('_CO_MTOOLS_' . 'ADMENU_FEEDBACK', 'Feedback'); +$define('_CO_MTOOLS_' . 'MIGRATE_OK', 'Database migrated to current schema.'); +$define('_CO_MTOOLS_' . 'MIGRATE_WARNING', 'Warning! This is intended for developers only. Confirm write schema file from current database.'); +$define('_CO_MTOOLS_' . 'MIGRATE_SCHEMA_OK', 'Current schema file written'); + +//Latest Version Check +$define('_CO_MTOOLS_' . 'NEW_VERSION', 'New Version: '); + +//DirectoryChecker +$define('_CO_MTOOLS_' . 'AVAILABLE', "Available"); +$define('_CO_MTOOLS_' . 'NOTAVAILABLE', "Not available"); +$define('_CO_MTOOLS_' . 'NOTWRITABLE', "Should have permission ( %d ), but it has ( %d )"); +$define('_CO_MTOOLS_' . 'CREATETHEDIR', 'Create it'); +$define('_CO_MTOOLS_' . 'SETMPERM', 'Set the permission'); +$define('_CO_MTOOLS_' . 'DIRCREATED', 'The directory has been created'); +$define('_CO_MTOOLS_' . 'DIRNOTCREATED', 'The directory cannot be created'); +$define('_CO_MTOOLS_' . 'PERMSET', 'The permission has been set'); +$define('_CO_MTOOLS_' . 'PERMNOTSET', 'The permission cannot be set'); + +//FileChecker +//\define('_CO_MTOOLS_AVAILABLE', "Available"); +//\define('_CO_MTOOLS_NOTAVAILABLE', "Not available"); +//\define('_CO_MTOOLS_NOTWRITABLE', "Should have permission ( %d ), but it has ( %d )"); +//\define('_CO_MTOOLS_COPYTHEFILE', 'Copy it'); +//\define('_CO_MTOOLS_CREATETHEFILE', 'Create it'); +//\define('_CO_MTOOLS_SETMPERM', 'Set the permission'); + +$define('_CO_MTOOLS_FILECOPIED', 'The file has been copied'); +$define('_CO_MTOOLS_FILENOTCOPIED', 'The file cannot be copied'); + +//\define('_CO_MTOOLS_PERMSET', 'The permission has been set'); +//\define('_CO_MTOOLS_PERMNOTSET', 'The permission cannot be set'); + +//image config +$define('_CO_MTOOLS_CONFIG_EXT_IMAGE', 'EXTERNAL Image configuration'); + +$define('_CO_MTOOLS_CONFIG_STYLING_START', ':: '); +$define('_CO_MTOOLS_CONFIG_STYLING_END', ' :: '); +$define('_CO_MTOOLS_CONFIG_STYLING_DESC_START', ''); +$define('_CO_MTOOLS_CONFIG_STYLING_DESC_END', ' '); + +$define('_CO_MTOOLS_PREFERENCE_BREAK_CONFIG_IMAGE', constant('_CO_MTOOLS_CONFIG_STYLING_START') . constant('_CO_MTOOLS_CONFIG_EXT_IMAGE') . constant('_CO_MTOOLS_CONFIG_STYLING_END')); +$define('_CO_MTOOLS_IMAGE_WIDTH', 'Image Display Width'); +$define('_CO_MTOOLS_IMAGE_WIDTH_DSC', 'Display width for image'); +$define('_CO_MTOOLS_IMAGE_HEIGHT', 'Image Display Height'); +$define('_CO_MTOOLS_IMAGE_HEIGHT_DSC', 'Display height for image'); +$define('_CO_MTOOLS_IMAGE_CONFIG', '--- EXTERNAL Image configuration --- '); +$define('_CO_MTOOLS_IMAGE_CONFIG_DSC', ''); +$define('_CO_MTOOLS_IMAGE_UPLOAD_PATH', 'Image Upload path'); +$define('_CO_MTOOLS_IMAGE_UPLOAD_PATH_DSC', 'Path for uploading images'); + +$define('_CO_MTOOLS_IMAGE_FILE_SIZE', 'Image File Size (in Bytes)'); +$define('_CO_MTOOLS_IMAGE_FILE_SIZE_DSC','The maximum file size of the image file (in Bytes)'); + +//Module Stats +$define('_CO_MTOOLS_STATS_SUMMARY', 'Module Statistics'); +$define('_CO_MTOOLS_TOTAL_CATEGORIES', 'Categories:'); +$define('_CO_MTOOLS_TOTAL_ITEMS', 'Items'); +$define('_CO_MTOOLS_TOTAL_OFFLINE', 'Offline'); +$define('_CO_MTOOLS_TOTAL_PUBLISHED', 'Published'); +$define('_CO_MTOOLS_TOTAL_REJECTED', 'Rejected'); +$define('_CO_MTOOLS_TOTAL_SUBMITTED', 'Submitted'); + +$define('_CO_MTOOLS_ERROR403', 'You are not allowed to view this page!'); + +//Preferences +$define('_CO_MTOOLS_TRUNCATE_LENGTH', 'Number of Characters to truncate to the long text field'); +$define('_CO_MTOOLS_TRUNCATE_LENGTH_DESC', 'Set the maximum number of characters to truncate the long text fields'); + +$define('_CO_MTOOLS_DELETE_BLOCK_CONFIRM', 'Are you sure to delete this Block?'); + +//Cloning +$define('_CO_MTOOLS_CLONE', 'Clone'); +$define('_CO_MTOOLS_CLONE_DSC', 'Cloning a module has never been this easy! Just type in the name you want for it and hit submit button!'); +$define('_CO_MTOOLS_CLONE_TITLE', 'Clone %s'); +$define('_CO_MTOOLS_CLONE_NAME', 'Choose a name for the new module'); +$define('_CO_MTOOLS_CLONE_NAME_DSC', 'Do not use special characters!
Do not choose an existing module dirname or database table name!'); +$define('_CO_MTOOLS_CLONE_INVALIDNAME', 'ERROR: Invalid module name, please try another one!'); +$define('_CO_MTOOLS_CLONE_EXISTS', 'ERROR: Module name already taken, please try another one!'); +$define('_CO_MTOOLS_CLONE_CONGRAT', 'Congratulations! %s was sucessfully created!
You may want to make changes in language files.'); +$define('_CO_MTOOLS_CLONE_IMAGEFAIL', 'Attention, we failed creating the new module logo. Please consider modifying assets/images/logo_module.png manually!'); +$define('_CO_MTOOLS_CLONE_FAIL', "Sorry, we failed in creating the new clone. Maybe you need to temporally set write permissions (CHMOD 777) to 'modules' folder and try again."); + +//JSON-LD generation of www.schema.org +$define('_CO_MTOOLS_GENERATE_JSONLD', 'Generate Schema Markup through JSON LD'); +$define('_CO_MTOOLS_GENERATE_JSONLD_DESC', 'Mark up your module with structured data to help search engines better understand the content of your web page'); + +//Repository not found +$define('_CO_MTOOLS_REPO_NOT_FOUND', 'Repository Not Found: '); +//Release not found +$define('_CO_MTOOLS_NO_REL_FOUND', 'Released Version Not Found: '); +//rename upload folder on uninstall +$define('_CO_MTOOLS_ERROR_FOLDER_RENAME_FAILED', 'Could not rename upload folder, please rename manually'); + +//TCPDF +$define('_CO_MTOOLS_ERROR_NO_PDF', 'TCPDF for XOOPS is not installed in /class/libraries/vendor/tecnickcom/tcpdf/
Please read the /docs/readme.txt or click on the Help tab to learn how to get it!'); diff --git a/resources/migration/class-reuse-decisions.php b/resources/migration/class-reuse-decisions.php new file mode 100644 index 0000000..456adf9 --- /dev/null +++ b/resources/migration/class-reuse-decisions.php @@ -0,0 +1,33 @@ + ['Constants', 'Helper', 'Utility'], + + // Current same-name candidates which were reviewed and intentionally retained. + 'decisions' => [ + 'article:text' => ['status' => 'retained', 'reason' => 'Article body persistence entity, not the ModuleTools text service.'], + 'myalbum:Text' => ['status' => 'retained', 'reason' => 'Module domain entity, not the ModuleTools text service.'], + 'myreviews:Text' => ['status' => 'retained', 'reason' => 'Module domain entity with permission/form behavior.'], + 'newbb:ObjectTree' => ['status' => 'retained', 'reason' => 'Forum-specific tree API (forum parents, tagged arrays, object trees).'], + 'newbb:Text' => ['status' => 'retained', 'reason' => 'Forum text persistence entity.'], + 'queries:migrate' => ['status' => 'retained', 'reason' => 'Module-specific schema migration subclass.'], + 'recette:ObjectTree' => ['status' => 'retained', 'reason' => 'Legacy SQL-tree contract; not compatible with object-array ObjectTree.'], + 'surnames:migrate' => ['status' => 'retained', 'reason' => 'Module-specific schema migration subclass.'], + 'teambios:Breadcrumb' => ['status' => 'retained', 'reason' => 'Compatibility no-op renderer; changing it would enable output unexpectedly.'], + 'wggallery:Resizer' => ['status' => 'delegated', 'reason' => 'Standard resize/crop delegates to ModuleTools; local grid merge/rotation remains.'], + 'wgteams:Resizer' => ['status' => 'delegated', 'reason' => 'Standard resize/crop delegates to ModuleTools; local grid composition remains.'], + 'xcontent:Text' => ['status' => 'retained', 'reason' => 'Content text persistence entity.'], + ], + + // Candidates removed after review; the audit verifies that they stay removed. + 'resolved' => [ + 'adslight:ObjectTree' => 'Empty pass-through replaced by Xoops\\ModuleTools\\Common\\ObjectTree.', + 'news:ObjectTree' => 'Duplicate object tree replaced by Xoops\\ModuleTools\\Common\\ObjectTree.', + 'publisher:Resizer' => 'Single resize caller converted to ResizeRequest/Resizer.', + 'team:TestdataButtons' => 'Unused copied helper removed; shared implementation owns the behavior.', + 'xnewsletter:Breadcrumb' => 'Duplicate renderer replaced by Xoops\\ModuleTools\\Common\\Breadcrumb.', + ], +]; diff --git a/resources/migration/destination-decisions.php b/resources/migration/destination-decisions.php new file mode 100644 index 0000000..d8bfdeb --- /dev/null +++ b/resources/migration/destination-decisions.php @@ -0,0 +1,302 @@ + array_replace([ + 'disposition' => 'retain-compat', + 'qualifier' => '', + 'coupling' => ['xoops-runtime'], + 'target_package' => 'none', + 'target_symbol' => 'none', + 'target_min_version' => 'none', + 'target_owner' => 'none', + 'target_status' => 'not-applicable', + 'availability' => 'not-applicable', + 'migration' => ['none-yet'], + 'rector_rules' => [], + 'rector_config_template' => '', + 'migration_safety' => 'manual', + 'capability_profile' => '', + 'parity_test' => 'missing', + 'upstream_issue' => '', + 'introduced_version' => '', + 'not_before_release' => '', + 'next_review_release' => '', + 'notes' => 'Frozen XOOPS 2.x compatibility surface.', +], $overrides); + +$decisions = []; + +$decisions['Admin\\Utility'] = $compat([ + 'qualifier' => 'legacy-collapsible-adapter', + 'coupling' => ['xoops-runtime', 'presentation-html'], + 'target_package' => 'xoops/smartyextensions', + 'target_symbol' => 'none', + 'target_min_version' => 'none', + 'target_owner' => 'XOOPS admin presentation maintainers', + 'target_status' => 'missing', + 'availability' => 'not-applicable', + 'migration' => ['none-yet'], + 'parity_test' => 'tests/Admin/UtilityTest.php', + 'notes' => 'Escaped compatibility output for legacy Smart-module collapsible admin sections.', +]); + +foreach ([ + 'Admin\\ObjectColumn', 'Admin\\ObjectController', 'Admin\\ObjectRow', 'Admin\\ObjectTable', + 'Admin\\SingleView', 'Admin\\TreeTable', 'Form\\ObjectFormBuilder', +] as $symbol) { + $decisions[$symbol] = $compat([ + 'qualifier' => 'then-migrate', + 'coupling' => ['xoops-runtime', 'xoopsobject-handler', 'request-global', 'presentation-html'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Presentation\\DataTable', + 'target_min_version' => '2.0', + 'target_owner' => 'XMF presentation and repository maintainers', + 'target_status' => 'experimental', + 'availability' => 'migrate-on-4.0', + 'migration' => ['adapter', 'manual-recipe'], + 'notes' => 'Keep frozen until the XMF presentation contract is stable.', + ]); +} + +foreach ([ + 'Object\\DynamicObject', 'Object\\SeoObject', 'Persistence\\PersistableHandler', + 'Persistence\\PersistableHandler2', 'Common\\ObjectTree', 'Common\\ModuleFeedback', +] as $symbol) { + $decisions[$symbol] = $compat([ + 'coupling' => ['xoops-runtime', 'xoopsobject-handler'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Bridge\\HandlerToRepositoryBridge', + 'target_min_version' => '2.0', + 'target_owner' => 'XMF bridge and repository maintainers', + 'target_status' => 'stable', + 'availability' => 'migrate-on-4.0', + 'migration' => ['adapter', 'manual-recipe'], + 'notes' => 'XOOPS 2.x object compatibility; consumers use the 4.0 bridge sequence.', + ]); +} + +foreach (['Common\\Db', 'Common\\DbInterface'] as $symbol) { + $decisions[$symbol] = $compat([ + 'coupling' => ['xoops-runtime', 'xoopsobject-handler'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Repository\\RepositoryInterface', + 'target_min_version' => '2.0', + 'target_owner' => 'XMF repository maintainers and module domain owners', + 'target_status' => 'stable', + 'availability' => 'migrate-on-4.0', + 'migration' => ['adapter', 'manual-recipe'], + 'notes' => 'Static database helpers remain compatibility-only; repositories own new persistence.', + ]); +} + +foreach (['Common\\Text', 'Common\\TextInterface', 'Common\\ServerStats', 'Common\\ModuleStats'] as $symbol) { + $decisions[$symbol] = $compat([ + 'qualifier' => 'find-owner', + 'coupling' => str_contains($symbol, 'Text') ? ['pure-php'] : ['xoops-runtime'], + 'notes' => 'Retained until an exact stable owner and parity corpus exist.', + ]); +} + +foreach (['Common\\Paginator', 'Common\\PaginationState', 'Common\\PaginationStateInterface'] as $symbol) { + $decisions[$symbol] = $compat([ + 'disposition' => 'migrate-to-target', + 'qualifier' => 'parity-blocked', + 'coupling' => 'Common\\Paginator' === $symbol + ? ['request-global', 'presentation-html'] : ['pure-php'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Pagination\\PaginatedResult', + 'target_min_version' => '1.5', + 'target_owner' => 'XMF pagination maintainers', + 'target_status' => 'stable', + 'availability' => 'delegate-now', + 'migration' => ['manual-recipe'], + 'parity_test' => 'missing', + 'notes' => 'The parity corpus documents non-equivalence (zero pages, bands, SQL and rendering); no shim yet.', + ]); +} + +foreach ([ + 'Common\\Resizer', 'Common\\ImageResizerInterface', 'Common\\ResizeRequest', + 'Common\\ResizeResult', 'Common\\Media\\MediaUploadRequest', +] as $symbol) { + $decisions[$symbol] = $compat([ + 'disposition' => 'blocked-on-upstream', + 'qualifier' => 'provisional-adapter', + 'coupling' => ['xoops-runtime'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Media\\Storage', + 'target_min_version' => '2.0', + 'target_owner' => 'XMF media and storage maintainers', + 'target_status' => 'missing', + 'availability' => 'migrate-on-4.0', + 'migration' => ['none-yet'], + 'notes' => 'No proven stable XMF media/storage contract currently matches this surface.', + ]); +} + +foreach ([ + 'Permission\\PermissionGatewayInterface', 'Permission\\XoopsPermissionGateway', + 'Permission\\ItemPermission', +] as $symbol) { + $decisions[$symbol] = $compat([ + 'disposition' => 'blocked-on-upstream', + 'qualifier' => 'provisional-adapter', + 'coupling' => ['xoops-runtime'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Permissions\\CoreGroupPermissionGateway', + 'target_min_version' => '1.5', + 'target_owner' => 'XMF permissions maintainers', + 'target_status' => 'provisional', + 'availability' => 'delegate-now', + 'migration' => ['adapter', 'manual-recipe', 'rector-rule'], + 'rector_rules' => ['Xoops\\Rector\\Rules\\ConfiguredPermissionGatewayRector'], + 'rector_config_template' => 'docs/XMF-MODERNIZATION.md#permission-families', + 'migration_safety' => 'configured', + 'capability_profile' => '1.5', + 'parity_test' => 'tests/Permission/ItemPermissionTest.php', + 'notes' => 'The local adapter is retained until upstream BC and write semantics are proven.', + ]); +} + +$decisions['Module\\ModuleContext'] = $compat([ + 'disposition' => 'promote', + 'qualifier' => 'deprecated-facade', + 'coupling' => ['xoops-runtime'], + 'target_package' => 'xoops/helpers', + 'target_symbol' => 'Xoops\\Helpers\\Service\\Path', + 'target_min_version' => '1.0', + 'target_owner' => 'XOOPS Helpers maintainers', + 'target_status' => 'stable', + 'availability' => 'delegate-now', + 'migration' => ['delegation', 'manual-recipe', 'rector-rule'], + 'rector_rules' => [ + 'Xoops\\Rector\\Rules\\ModuleConfigToHelperRector', + 'Xoops\\Rector\\Rules\\ModulePathUrlToHelperRector', + ], + 'rector_config_template' => 'bin/xoops-module-profile.php', + 'migration_safety' => 'configured', + 'capability_profile' => '1.5', + 'parity_test' => 'tests/Migration/ModuleContextDelegationTest.php', + 'notes' => 'Facade delegates path, URL and config operations to Helpers services.', +]); + +$decisions['Module\\NamespaceAutoloader'] = $compat([ + 'qualifier' => 'transitional-autoloader', + 'coupling' => ['pure-php'], + 'parity_test' => 'tests/Module/NamespaceAutoloaderTest.php', + 'notes' => 'XOOPS 2.x bridge for namespaced modules that still retain legacy class filenames; Composer-native module packaging supersedes it after migration.', +]); + +foreach (['Common\\DirectoryChecker', 'Common\\FileChecker', 'Common\\FilesManagement'] as $symbol) { + $decisions[$symbol] = $compat([ + // DirectoryChecker also renders admin action forms and redirects (handleRequest), so it stays compat. + 'disposition' => 'Common\\DirectoryChecker' === $symbol ? 'retain-compat' : 'promote', + 'qualifier' => 'delegating-shim', + 'coupling' => ['xoops-runtime'], + 'target_package' => 'xoops/helpers', + 'target_symbol' => 'Xoops\\Helpers\\Utility\\Filesystem', + 'target_min_version' => '1.0', + 'target_owner' => 'XOOPS Helpers maintainers', + 'target_status' => 'stable', + 'availability' => 'delegate-now', + 'migration' => ['delegation', 'manual-recipe'], + 'parity_test' => 'tests/Migration/FilesystemDelegationTest.php', + 'notes' => 'Heavy filesystem operations already delegate; XOOPS guards stay in the compatibility shim.', + ]); +} + +$filesystemRetainedMethods = [ + 'Common\\DirectoryChecker::getDirectoryStatus()', + 'Common\\DirectoryChecker::setDirectoryPermissions()', + 'Common\\DirectoryChecker::dirExists()', + 'Common\\FileChecker::getFileStatus()', + 'Common\\FileChecker::compareFiles()', + 'Common\\FileChecker::fileExists()', + 'Common\\FileChecker::setFilePermissions()', +]; +foreach ($filesystemRetainedMethods as $symbol) { + $decisions[$symbol] = $compat([ + 'qualifier' => 'method-specific-residue', + 'coupling' => str_contains($symbol, 'Status') ? ['xoops-runtime', 'presentation-html'] : ['pure-php'], + 'notes' => 'This member has no exact Filesystem equivalent and remains frozen compatibility residue.', + ]); +} + +$decisions['Common\\Migrate'] = $compat([ + 'disposition' => 'promote', + 'qualifier' => 'delegating-shim', + 'coupling' => ['xoops-runtime'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Database\\Migrate', + 'target_min_version' => '1.5', + 'target_owner' => 'XMF database maintainers', + 'target_status' => 'stable', + 'availability' => 'delegate-now', + 'migration' => ['delegation', 'manual-recipe'], + 'rector_config_template' => 'bin/xoops-schema-plan.php', + 'migration_safety' => 'plan-only', + 'capability_profile' => '1.5', + 'parity_test' => 'tests/Migration/MigrateDelegationTest.php', + 'notes' => 'ModuleTools subclasses XMF only to preserve configurator-driven table renames.', +]); + +foreach (['Common\\Breadcrumb', 'Common\\SocialBookmarks', 'Common\\LetterChoice'] as $symbol) { + $decisions[$symbol] = $compat([ + 'disposition' => 'blocked-on-upstream', + 'qualifier' => 'find-exact-presentation-owner', + 'coupling' => ['presentation-html'], + 'target_package' => 'xoops/smartyextensions', + 'target_symbol' => 'none', + 'target_min_version' => 'none', + 'target_owner' => 'SmartyExtensions navigation maintainers', + 'target_status' => 'missing', + 'availability' => 'not-applicable', + 'migration' => ['none-yet'], + 'notes' => 'Installed presentation helpers do not expose an exact equivalent API.', + ]); +} + +foreach (['Common\\TestdataButtons', 'Common\\TestdataSample'] as $symbol) { + $decisions[$symbol] = $compat([ + 'disposition' => 'migrate-to-target', + 'qualifier' => 'await-4.0', + 'coupling' => ['xoops-runtime', 'presentation-html'], + 'target_package' => 'xoops/xmf', + 'target_symbol' => 'Xmf\\Module\\Testdata', + 'target_min_version' => '2.0', + 'target_owner' => 'XMF module tooling maintainers', + 'target_status' => 'provisional', + 'availability' => 'migrate-on-4.0', + 'migration' => ['manual-recipe'], + 'notes' => 'The target is absent from XMF 1.5 and must be proven in the PHP 8.4 tree.', + ]); +} + +$simpleCompat = [ + 'Module\\Installer' => ['then-migrate', ['xoops-runtime', 'request-global', 'presentation-html']], + 'Module\\Dependency' => ['reassess', ['xoops-runtime']], + 'Module\\ConsumerRuntime' => ['reassess', ['xoops-runtime', 'presentation-html']], + 'Bootstrap' => ['reassess', ['xoops-runtime']], + 'Common\\Blocksadmin' => ['', ['xoops-runtime', 'request-global', 'presentation-html']], + 'Common\\VersionChecks' => ['', ['xoops-runtime']], + 'Common\\ModuleConfig' => ['reassess-ownership', ['xoops-runtime']], + 'Common\\StandardConfig' => ['reassess-ownership', ['xoops-runtime']], + 'Common\\Configurator' => ['reassess-ownership', ['xoops-runtime']], + 'Common\\Output' => ['until-output-modeled', ['xoops-runtime', 'presentation-html']], + 'Common\\Confirm' => ['until-output-modeled', ['xoops-runtime', 'presentation-html']], + 'Common\\UpdateChecker' => ['reassess', ['xoops-runtime', 'presentation-html']], + 'Common\\SysUtility' => ['split-first-per-method', ['xoops-runtime', 'request-global', 'presentation-html']], + 'Utility' => ['split-first-per-method', ['xoops-runtime', 'request-global', 'presentation-html']], + 'Constants' => ['', ['pure-php']], + // CSV download and module cloning emit HTTP headers/redirects by design. + 'Admin\\Export' => ['', ['xoops-runtime', 'xoopsobject-handler', 'presentation-html']], + 'Common\\Cloner' => ['', ['xoops-runtime', 'request-global', 'presentation-html']], + 'Admin\\Table' => ['legacy-alias', ['xoops-runtime', 'presentation-html']], + 'Common\\Highlighter' => ['', ['pure-php']], +]; +foreach ($simpleCompat as $symbol => [$qualifier, $coupling]) { + $decisions[$symbol] = $compat(['qualifier' => $qualifier, 'coupling' => $coupling]); +} + +return $decisions; diff --git a/scripts/check-api-compat.php b/scripts/check-api-compat.php new file mode 100644 index 0000000..ed2d1e2 --- /dev/null +++ b/scripts/check-api-compat.php @@ -0,0 +1,67 @@ + []]; +$allowed = array_fill_keys($allowlist['allowed_changes'] ?? [], true); +$changes = []; + +compareApi($baseline['symbols'], $current['symbols'], 'symbols', $changes); +compareApi($baseline['global_functions'], $current['global_functions'], 'global_functions', $changes); +compareApi($baseline['alias_rules'], $current['alias_rules'], 'alias_rules', $changes); +$unexpected = array_values(array_filter($changes, static fn (string $change): bool => !isset($allowed[$change]))); + +if ([] !== $unexpected) { + fwrite(STDERR, "Unexpected public API changes:\n- " . implode("\n- ", $unexpected) . "\n"); + exit(1); +} + +fwrite(STDOUT, sprintf("API compatibility gate passed (%d allowlisted changes).\n", count($changes))); + +/** @param mixed $before @param mixed $after @param list $changes */ +function compareApi(mixed $before, mixed $after, string $path, array &$changes): void +{ + if (gettype($before) !== gettype($after)) { + $changes[] = $path . ':type-changed'; + return; + } + if (!is_array($before)) { + if ($before !== $after) { + $changes[] = $path . ':value-changed'; + } + return; + } + foreach ($before as $key => $value) { + $child = $path . '/' . (string)$key; + if (!array_key_exists($key, $after)) { + $changes[] = $child . ':removed'; + continue; + } + compareApi($value, $after[$key], $child, $changes); + } + foreach ($after as $key => $_value) { + if (!array_key_exists($key, $before)) { + $changes[] = $path . '/' . (string)$key . ':added'; + } + } +} diff --git a/scripts/check-destination-matrix.php b/scripts/check-destination-matrix.php new file mode 100644 index 0000000..7b2429e --- /dev/null +++ b/scripts/check-destination-matrix.php @@ -0,0 +1,65 @@ + ['retain-compat', 'promote', 'migrate-to-target', 'blocked-on-upstream', 'remove'], + 'coupling' => ['pure-php', 'xoops-runtime', 'xoopsobject-handler', 'request-global', 'presentation-html'], + 'target_status' => ['stable', 'provisional', 'experimental', 'missing', 'not-applicable'], + 'availability' => ['delegate-now', 'migrate-on-4.0', 'not-applicable'], + 'migration' => ['delegation', 'adapter', 'rector-rule', 'manual-recipe', 'none-yet'], +]; +$errors = []; +$seen = []; +foreach ($matrix['records'] as $record) { + $seen[$record['symbol']] = true; + foreach (['disposition', 'target_status', 'availability'] as $field) { + if (!in_array($record[$field], $valid[$field], true)) { + $errors[] = $record['symbol'] . ': invalid ' . $field; + } + } + foreach (['coupling', 'migration'] as $field) { + if ([] === $record[$field] || array_diff($record[$field], $valid[$field])) { + $errors[] = $record['symbol'] . ': invalid ' . $field; + } + } + if (str_contains($record['symbol'], '\\Transitional\\')) { + foreach (['upstream_issue', 'introduced_version', 'not_before_release', 'next_review_release'] as $field) { + if ('' === $record[$field]) { + $errors[] = $record['symbol'] . ': transitional record missing ' . $field; + } + } + } + if ('delegate-now' === $record['availability'] && 'none' === $record['target_symbol']) { + $errors[] = $record['symbol'] . ': delegate-now record has no exact target'; + } +} + +$surface = json_decode((string) file_get_contents($packageRoot . '/resources/gate/api-surface.json'), true, flags: JSON_THROW_ON_ERROR); +foreach ($surface['symbols'] as $symbol => $definition) { + $expected = [$symbol]; + foreach (array_keys($definition['constants']) as $name) { + $expected[] = $symbol . '::' . $name; + } + foreach (array_keys($definition['properties']) as $name) { + $expected[] = $symbol . '::$' . $name; + } + foreach (array_keys($definition['methods']) as $name) { + $expected[] = $symbol . '::' . $name . '()'; + } + foreach ($expected as $name) { + if (!isset($seen[$name])) { + $errors[] = 'Missing matrix record: ' . $name; + } + } +} + +if ([] !== $errors) { + fwrite(STDERR, implode(PHP_EOL, $errors) . PHP_EOL); + exit(1); +} + +$transitional = array_filter($matrix['records'], static fn (array $r): bool => str_contains($r['symbol'], '\\Transitional\\')); +fwrite(STDOUT, sprintf("Destination matrix valid; %d unresolved transitional contracts.\n", count($transitional))); diff --git a/scripts/check-global-accessors.php b/scripts/check-global-accessors.php new file mode 100644 index 0000000..31ee322 --- /dev/null +++ b/scripts/check-global-accessors.php @@ -0,0 +1,63 @@ +createForNewestSupportedVersion(); +$errors = []; +$count = 0; +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($packageRoot . '/src', FilesystemIterator::SKIP_DOTS), +); +foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || 'php' !== $file->getExtension()) { + continue; + } + $ast = $parser->parse((string) file_get_contents($file->getPathname())) ?? []; + foreach ($ast as $node) { + inspectGlobals($node, '', $file->getPathname(), $errors, $count); + } +} + +if ([] !== $errors) { + fwrite(STDERR, implode(PHP_EOL, $errors) . PHP_EOL); + exit(1); +} +fwrite(STDOUT, sprintf("Legacy-global boundary valid (%d accessor reads/writes).\n", $count)); + +/** @param list $errors */ +function inspectGlobals(Node $node, string $methodDoc, string $file, array &$errors, int &$count): void +{ + if ($node instanceof Node\Stmt\ClassMethod) { + $methodDoc = $node->getDocComment()?->getText() ?? ''; + } + if ($node instanceof Node\Expr\ArrayDimFetch + && $node->var instanceof Node\Expr\Variable + && 'GLOBALS' === $node->var->name) { + ++$count; + if (!str_contains($methodDoc, '@legacy-global-accessor')) { + $errors[] = $file . ':' . $node->getStartLine() . ' $GLOBALS access is outside a designated accessor'; + } + } + foreach ($node->getSubNodeNames() as $subNodeName) { + $child = $node->{$subNodeName}; + if ($child instanceof Node) { + inspectGlobals($child, $methodDoc, $file, $errors, $count); + } elseif (is_array($child)) { + foreach ($child as $item) { + if ($item instanceof Node) { + inspectGlobals($item, $methodDoc, $file, $errors, $count); + } + } + } + } +} diff --git a/scripts/check-layers.php b/scripts/check-layers.php new file mode 100644 index 0000000..b83cef8 --- /dev/null +++ b/scripts/check-layers.php @@ -0,0 +1,75 @@ +createForNewestSupportedVersion(); +$finder = new NodeFinder(); +$errors = []; +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($packageRoot . '/src', FilesystemIterator::SKIP_DOTS), +); +foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || 'php' !== $file->getExtension()) { + continue; + } + $source = (string) file_get_contents($file->getPathname()); + $ast = $parser->parse($source) ?? []; + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $ast = $traverser->traverse($ast); + $classes = $finder->find($ast, static fn (Node $node): bool => $node instanceof Node\Stmt\Class_ + || $node instanceof Node\Stmt\Interface_ || $node instanceof Node\Stmt\Trait_); + foreach ($classes as $class) { + if (!isset($class->namespacedName)) { + continue; + } + $name = $class->namespacedName->toString(); + $doc = $class->getDocComment()?->getText() ?? ''; + if (str_contains($name, '\\Internal\\') && !str_contains($doc, '@internal')) { + $errors[] = $name . ': Internal class is missing @internal'; + } + + $allowsOutput = isset($outputCompatibility[$name]); + $outputNodes = $finder->find($class->stmts, static function (Node $node): bool { + if ($node instanceof Node\Stmt\Echo_) { + return true; + } + if (!$node instanceof Node\Expr\FuncCall || !$node->name instanceof Node\Name) { + return false; + } + return in_array(strtolower($node->name->toString()), ['redirect_header', 'header'], true); + }); + if (!$allowsOutput && [] !== $outputNodes) { + $errors[] = $name . ': transport output outside retain-compat'; + } + } +} + +if ([] !== $errors) { + fwrite(STDERR, implode(PHP_EOL, $errors) . PHP_EOL); + exit(1); +} +fwrite(STDOUT, "Layer and output boundaries are valid.\n"); diff --git a/scripts/generate-api-surface.php b/scripts/generate-api-surface.php new file mode 100644 index 0000000..3645566 --- /dev/null +++ b/scripts/generate-api-surface.php @@ -0,0 +1,214 @@ +createForNewestSupportedVersion(); +$printer = new Standard(); +$finder = new NodeFinder(); +$surface = [ + 'schema_version' => 1, + 'package' => 'xoops/moduletools', + 'autoload' => json_decode((string) file_get_contents($packageRoot . '/composer.json'), true)['autoload'] ?? [], + 'alias_rules' => [], + 'global_functions' => [], + 'symbols' => [], +]; + +$files = []; +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($packageRoot . '/src', FilesystemIterator::SKIP_DOTS), +); +foreach ($iterator as $file) { + if ($file instanceof SplFileInfo && 'php' === $file->getExtension()) { + $files[] = $file->getPathname(); + } +} +sort($files); + +foreach ($files as $file) { + $source = file_get_contents($file); + if (!is_string($source)) { + throw new RuntimeException('Unable to read ' . $file); + } + + $ast = $parser->parse($source); + if (null === $ast) { + continue; + } + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $ast = $traverser->traverse($ast); + $relative = str_replace('\\', '/', substr($file, strlen($packageRoot) + 1)); + + foreach ($finder->findInstanceOf($ast, Stmt\Function_::class) as $function) { + if (!$function instanceof Stmt\Function_ || !isset($function->namespacedName)) { + continue; + } + $surface['global_functions'][$function->namespacedName->toString()] = functionRecord($function, $printer); + } + + foreach ( + $finder->find($ast, static fn (Node $node): bool => $node instanceof Stmt\Class_ + || $node instanceof Stmt\Interface_ || $node instanceof Stmt\Trait_) as $symbol + ) { + if (!isset($symbol->namespacedName)) { + continue; + } + + $name = $symbol->namespacedName->toString(); + $symbolDoc = $symbol->getDocComment()?->getText() ?? ''; + if (str_contains($name, '\\Internal\\') && str_contains($symbolDoc, '@internal')) { + continue; + } + $kind = $symbol instanceof Stmt\Interface_ ? 'interface' + : ($symbol instanceof Stmt\Trait_ ? 'trait' : 'class'); + $record = [ + 'kind' => $kind, + 'file' => $relative, + 'final' => $symbol instanceof Stmt\Class_ && $symbol->isFinal(), + 'abstract' => $symbol instanceof Stmt\Class_ && $symbol->isAbstract(), + 'extends' => [], + 'implements' => [], + 'constants' => [], + 'properties' => [], + 'methods' => [], + ]; + + if ($symbol instanceof Stmt\Class_ && null !== $symbol->extends) { + $record['extends'][] = $symbol->extends->toString(); + } elseif ($symbol instanceof Stmt\Interface_) { + $record['extends'] = array_map(static fn (Node\Name $n): string => $n->toString(), $symbol->extends); + } + if ($symbol instanceof Stmt\Class_) { + $record['implements'] = array_map(static fn (Node\Name $n): string => $n->toString(), $symbol->implements); + } + + foreach ($symbol->stmts as $member) { + if ($member instanceof Stmt\ClassConst && $member->isPublic()) { + foreach ($member->consts as $constant) { + $record['constants'][$constant->name->toString()] = [ + 'type' => typeString($member->type), + 'value' => $printer->prettyPrintExpr($constant->value), + ]; + } + } elseif ($member instanceof Stmt\Property && $member->isPublic()) { + foreach ($member->props as $property) { + $record['properties'][$property->name->toString()] = [ + 'static' => $member->isStatic(), + 'readonly' => $member->isReadonly(), + 'type' => typeString($member->type), + 'default' => null === $property->default ? null : $printer->prettyPrintExpr($property->default), + ]; + } + } elseif ($member instanceof Stmt\ClassMethod && $member->isPublic()) { + $record['methods'][$member->name->toString()] = functionRecord($member, $printer); + // Promoted constructor parameters are public properties too. + foreach ($member->params as $parameter) { + if ($parameter->isPublic() && $parameter->var instanceof Node\Expr\Variable && is_string($parameter->var->name)) { + $record['properties'][$parameter->var->name] = [ + 'static' => false, + 'readonly' => $parameter->isReadonly(), + 'type' => typeString($parameter->type), + 'default' => null === $parameter->default ? null : $printer->prettyPrintExpr($parameter->default), + ]; + } + } + } + } + + ksort($record['constants']); + ksort($record['properties']); + ksort($record['methods']); + $surface['symbols'][$name] = $record; + } + + if ('src/legacy_aliases.php' === $relative) { + $surface['alias_rules'][] = [ + 'legacy_prefix' => 'XoopsModules\\Mtools\\', + 'modern_prefix' => 'Xoops\\ModuleTools\\', + 'mechanism' => 'lazy class_alias autoloader', + 'file' => $relative, + ]; + } +} + +ksort($surface['global_functions']); +ksort($surface['symbols']); +$json = json_encode($surface, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; + +if ($checkOnly) { + $existing = is_file($outputFile) ? file_get_contents($outputFile) : false; + if ($existing !== $json) { + fwrite(STDERR, "API surface is stale. Run composer api:generate.\n"); + exit(1); + } + fwrite(STDOUT, "API surface is current.\n"); + exit(0); +} + +file_put_contents($outputFile, $json); +fwrite(STDOUT, sprintf("Wrote %s (%d symbols).\n", $outputFile, count($surface['symbols']))); + +/** @return array */ +function functionRecord(Stmt\Function_|Stmt\ClassMethod $function, Standard $printer): array +{ + $parameters = []; + foreach ($function->params as $parameter) { + $parameters[] = [ + 'name' => is_string($parameter->var->name) ? $parameter->var->name : '', + 'type' => typeString($parameter->type), + 'by_ref' => $parameter->byRef, + 'variadic' => $parameter->variadic, + 'has_default' => null !== $parameter->default, + 'default' => null === $parameter->default ? null : $printer->prettyPrintExpr($parameter->default), + ]; + } + + $doc = $function->getDocComment()?->getText() ?? ''; + preg_match_all('/@throws\s+([^\s*]+)/', $doc, $matches); + + return [ + 'static' => $function instanceof Stmt\ClassMethod && $function->isStatic(), + 'by_ref' => $function->byRef, + 'parameters' => $parameters, + 'return_type' => typeString($function->returnType), + 'throws' => array_values(array_unique($matches[1] ?? [])), + 'documented_false_or_null' => (bool) preg_match('/(?:false|null)/i', $doc), + ]; +} + +function typeString(Node\Identifier|Node\Name|Node\ComplexType|null $type): ?string +{ + if (null === $type) { + return null; + } + if ($type instanceof Node\NullableType) { + return '?' . typeString($type->type); + } + if ($type instanceof Node\UnionType) { + return implode('|', array_map(typeString(...), $type->types)); + } + if ($type instanceof Node\IntersectionType) { + return implode('&', array_map(typeString(...), $type->types)); + } + + return $type->toString(); +} diff --git a/scripts/generate-destination-matrix.php b/scripts/generate-destination-matrix.php new file mode 100644 index 0000000..f4cd009 --- /dev/null +++ b/scripts/generate-destination-matrix.php @@ -0,0 +1,124 @@ + []]; +$decisions = require $packageRoot . '/resources/migration/destination-decisions.php'; +$matrix = ['schema_version' => 1, 'records' => []]; + +foreach ($surface['symbols'] as $fqcn => $symbol) { + $relative = str_replace('Xoops\\ModuleTools\\', '', $fqcn); + if (!isset($decisions[$relative])) { + throw new RuntimeException('Missing destination decision for ' . $fqcn); + } + $decision = $decisions[$relative]; + addRecord($matrix['records'], $fqcn, 'type', $decision, consumerUsage($fqcn, $audit)); + foreach (array_keys($symbol['constants']) as $constant) { + addRecord($matrix['records'], $fqcn . '::' . $constant, 'constant', $decision, consumerUsage($fqcn . '::' . $constant, $audit)); + } + foreach (array_keys($symbol['properties']) as $property) { + addRecord($matrix['records'], $fqcn . '::$' . $property, 'property', $decision, consumerUsage($fqcn, $audit)); + } + foreach (array_keys($symbol['methods']) as $method) { + $memberKey = $relative . '::' . $method . '()'; + $memberDecision = $decisions[$memberKey] ?? $decision; + addRecord($matrix['records'], $fqcn . '::' . $method . '()', 'method', $memberDecision, consumerUsage($fqcn . '::' . $method, $audit)); + } +} + +usort($matrix['records'], static fn (array $a, array $b): int => $a['symbol'] <=> $b['symbol']); +$json = json_encode($matrix, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; +$markdown = renderMatrix($matrix); +$backlog = renderBacklog($matrix); +$targets = [ + $packageRoot . '/resources/gate/destination-matrix.json' => $json, + $packageRoot . '/resources/gate/destination-matrix.md' => $markdown, + $packageRoot . '/resources/gate/promotion-backlog.md' => $backlog, +]; + +if ($checkOnly) { + foreach ($targets as $file => $contents) { + if (!is_file($file) || file_get_contents($file) !== $contents) { + fwrite(STDERR, "Destination artifacts are stale. Run composer matrix:generate.\n"); + exit(1); + } + } + fwrite(STDOUT, "Destination matrix is current.\n"); + exit(0); +} +foreach ($targets as $file => $contents) { + file_put_contents($file, $contents); +} +fwrite(STDOUT, sprintf("Wrote %d destination records.\n", count($matrix['records']))); + +/** @param list> $records @param array $decision @param array $usage */ +function addRecord(array &$records, string $symbol, string $kind, array $decision, array $usage): void +{ + $records[] = ['symbol' => $symbol, 'kind' => $kind] + $decision + ['consumers' => $usage]; +} + +/** @param array $audit @return array{usage_count:int, modules:list} */ +function consumerUsage(string $symbol, array $audit): array +{ + $lookup = str_replace('()', '', $symbol); + $modules = []; + $count = 0; + foreach ($audit['modules'] as $module => $record) { + $hits = ($record['modern_symbols'][$lookup] ?? 0) + ($record['legacy_symbols'][str_replace( + 'Xoops\\ModuleTools\\', + 'XoopsModules\\Mtools\\', + $lookup, + )] ?? 0) + ($record['static_calls'][$lookup] ?? 0); + if (0 < $hits) { + $modules[] = $module; + $count += $hits; + } + } + return ['usage_count' => $count, 'modules' => $modules]; +} + +/** @param array $matrix */ +function renderMatrix(array $matrix): string +{ + $lines = [ + '# ModuleTools destination matrix', '', + '> Generated from the API surface, consumer audit, and `resources/migration/destination-decisions.php`.', '', + '| Symbol | Kind | Disposition | Availability | Target | Status | Uses | Parity |', + '|---|---|---|---|---|---|---:|---|', + ]; + foreach ($matrix['records'] as $record) { + $target = 'none' === $record['target_symbol'] ? '—' : $record['target_symbol']; + $lines[] = sprintf( + '| `%s` | %s | %s%s | %s | `%s` | %s | %d | `%s` |', + $record['symbol'], $record['kind'], $record['disposition'], + '' === $record['qualifier'] ? '' : ' (' . $record['qualifier'] . ')', + $record['availability'], $target, $record['target_status'], + $record['consumers']['usage_count'], $record['parity_test'], + ); + } + return implode(PHP_EOL, $lines) . PHP_EOL; +} + +/** @param array $matrix */ +function renderBacklog(array $matrix): string +{ + $lines = ['# ModuleTools promotion backlog', '', '> Generated from unresolved type-level destination records.', '']; + foreach ($matrix['records'] as $record) { + if ('type' !== $record['kind'] || ('blocked-on-upstream' !== $record['disposition'] + && 'missing' !== $record['parity_test'])) { + continue; + } + $lines[] = '## ' . $record['symbol']; + $lines[] = ''; + $lines[] = '- Target: `' . $record['target_package'] . '` / `' . $record['target_symbol'] . '` (' . $record['target_status'] . ').'; + $lines[] = '- Blocker: ' . $record['notes']; + $lines[] = '- Required evidence: behavioral parity corpus plus an explicit stable owner.'; + $lines[] = ''; + } + return implode(PHP_EOL, $lines) . PHP_EOL; +} diff --git a/scripts/lint-php.php b/scripts/lint-php.php new file mode 100644 index 0000000..f600bb5 --- /dev/null +++ b/scripts/lint-php.php @@ -0,0 +1,38 @@ +getExtension()) { + continue; + } + + $command = escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file->getPathname()) . ' 2>&1'; + exec($command, $output, $exitCode); + if (0 !== $exitCode) { + $failures[] = implode(PHP_EOL, $output); + } + $output = []; + } +} + +if ([] !== $failures) { + fwrite(STDERR, implode(PHP_EOL, $failures) . PHP_EOL); + exit(1); +} + +fwrite(STDOUT, "PHP syntax check passed.\n"); diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..130cd58 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,12 @@ +sonar.organization=xoops +sonar.projectKey=XOOPS_moduletools +sonar.projectName=ModuleTools + +sonar.sources=src +sonar.tests=tests +sonar.sourceEncoding=UTF-8 + +sonar.php.version=8.4 +sonar.php.coverage.reportPaths=coverage.xml + +sonar.exclusions=vendor/**,stubs/**,build/** diff --git a/src/Admin/Export.php b/src/Admin/Export.php new file mode 100644 index 0000000..8140b12 --- /dev/null +++ b/src/Admin/Export.php @@ -0,0 +1,64 @@ +outputMethods = $methods; + } + public function setNotDisplayFields(array|string $fields): void + { + $this->excluded = array_values(array_unique([...$this->excluded, ...(array) $fields])); + } + + public function render(string $filename): never + { + if (headers_sent()) { + throw new \RuntimeException('CSV export headers have already been sent.'); + } + $safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', basename($filename)) ?: 'export.csv'; + if (!str_ends_with(strtolower($safeName), '.csv')) { + $safeName .= '.csv'; + } + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . $safeName . '"'); + header('Cache-Control: private, no-store'); + $stream = fopen('php://output', 'wb'); + if (false === $stream) { + throw new \RuntimeException('Unable to open the CSV output stream.'); + } + fwrite($stream, "\xEF\xBB\xBF"); + $headerWritten = false; + foreach ($this->handler->getObjects($this->criteria) as $object) { + $keys = array_values(array_filter(array_keys($object->vars), fn (string $key): bool => (false === $this->fields || in_array($key, $this->fields, true)) && !in_array($key, $this->excluded, true))); + if (!$headerWritten) { + fputcsv($stream, array_map(fn (string $key): string => (string) ($object->vars[$key]['form_caption'] ?? $key), $keys), escape: ''); + $headerWritten = true; + } + $row = []; + foreach ($keys as $key) { + $method = false !== $this->outputMethods ? ($this->outputMethods[$key] ?? null) : null; + $value = is_string($method) && method_exists($object, $method) ? $object->{$method}() : $object->getVar($key, 's'); + $row[] = is_array($value) ? implode(', ', array_map(strval(...), $value)) : (string) $value; + } + fputcsv($stream, $row, escape: ''); + } + fclose($stream); + exit; + } +} diff --git a/src/Admin/ObjectColumn.php b/src/Admin/ObjectColumn.php new file mode 100644 index 0000000..858d336 --- /dev/null +++ b/src/Admin/ObjectColumn.php @@ -0,0 +1,19 @@ +vars as $name => $metadata) { + if (false === ($metadata['persistent'] ?? true) || !array_key_exists($name, $_POST)) { + continue; + } + $value = $_POST[$name]; + if (in_array((int) $metadata['data_type'], [XOBJ_DTYPE_STIME, XOBJ_DTYPE_MTIME, XOBJ_DTYPE_LTIME], true)) { + if (is_array($value) && isset($value['date'])) { + $value = strtotime((string) $value['date']) + (int) ($value['time'] ?? 0); + } elseif (!is_numeric($value)) { + $value = strtotime((string) $value) ?: 0; + } + } + $object->setVar((string) $name, $value); + } + } + + public function storeFromDefaultForm(string $createdMessage, string $modifiedMessage, string|false|null $redirectPage = false, bool $debug = false, bool $extended = false): \XoopsObject|false + { + if ('POST' !== ($_SERVER['REQUEST_METHOD'] ?? 'GET')) { + return false; + } + $security = self::runtimeSecurity(); + if (is_object($security) && !$security->check()) { + throw new \RuntimeException(defined('_NOPERM') ? _NOPERM : 'Invalid security token.'); + } + + $key = (string) $this->handler->keyName; + $id = Request::getInt($key, 0, 'POST'); + $object = $id > 0 ? $this->handler->get($id) : $this->handler->create(); + if (!$object instanceof \XoopsObject || ($id > 0 && $object->isNew())) { + return false; + } + $isNew = $object->isNew(); + $this->postDataToObject($object); + $this->receiveUploads($object); + if (!$this->handler->insert($object)) { + return false; + } + + if (method_exists($this->handler, 'getPermissions') && method_exists($this->handler, 'getModuleInfo')) { + $module = $this->handler->getModuleInfo(); + if (is_object($module)) { + $permissions = ItemPermission::forModule($module); + foreach ((array) $this->handler->getPermissions() as $definition) { + $name = (string) ($definition['perm_name'] ?? ''); + if ('' !== $name) { + $permissions->replace($name, (int) $object->getVar($key, 'n'), array_map(intval(...), (array) ($_POST[$name] ?? []))); + } + } + } + } + + if (null !== $redirectPage) { + $target = false === $redirectPage ? (string) ($_SERVER['HTTP_REFERER'] ?? xoops_getenv('SCRIPT_NAME')) : $redirectPage; + redirect_header($target, 2, $isNew ? $createdMessage : $modifiedMessage); + } + + return $object; + } + + public function storeSmartObject(bool $debug = false, bool $extended = false): \XoopsObject|false + { + return $this->storeFromDefaultForm('', '', null, $debug, $extended); + } + + public function storeSmartObjectD(): \XoopsObject|false + { + return $this->storeSmartObject(true); + } + + public function handleObjectDeletion(string|false $confirmMessage = false, string $operation = 'del', bool $userSide = false): void + { + $key = (string) $this->handler->keyName; + $id = 'POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') ? Request::getInt($key, 0, 'POST') : Request::getInt($key, 0, 'GET'); + $object = $id > 0 ? $this->handler->get($id) : false; + if (!$object instanceof \XoopsObject || $object->isNew()) { + redirect_header((string) ($_SERVER['HTTP_REFERER'] ?? xoops_getenv('SCRIPT_NAME')), 3, defined('_NOPERM') ? _NOPERM : 'Record not found.'); + } + if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') && Request::getInt('confirm', 0, 'POST')) { + $security = self::runtimeSecurity(); + if (is_object($security) && !$security->check()) { + throw new \RuntimeException(defined('_NOPERM') ? _NOPERM : 'Invalid security token.'); + } + $redirect = Request::getString('redirect_page', xoops_getenv('SCRIPT_NAME'), 'POST'); + $ok = $this->handler->delete($object); + redirect_header($redirect, 2, $ok ? (defined('_DELETEDSUCCESS') ? _DELETEDSUCCESS : 'Deleted.') : (defined('_ERRORS') ? _ERRORS : 'Delete failed.')); + } + + $message = false === $confirmMessage ? (defined('_DELETE') ? _DELETE : 'Delete this record?') : $confirmMessage; + xoops_confirm( + ['op' => $operation, $key => $id, 'confirm' => 1, 'redirect_page' => (string) ($_SERVER['HTTP_REFERER'] ?? xoops_getenv('SCRIPT_NAME'))], + xoops_getenv('SCRIPT_NAME'), + sprintf($message, (string) $object->getVar((string) $this->handler->identifierName, 's')), + defined('_DELETE') ? _DELETE : 'Delete', + ); + } + + public function handleObjectDeletionFromUserSide(string|false $confirmMessage = false, string $operation = 'del'): void + { + $this->handleObjectDeletion($confirmMessage, $operation, true); + } + + private function receiveUploads(\XoopsObject $object): void + { + if (!method_exists($this->handler, 'getImagePath')) { + return; + } + foreach ($_FILES as $field => $file) { + $name = str_starts_with((string) $field, 'upload_') ? substr((string) $field, 7) : (string) $field; + if (!isset($object->vars[$name], $file['name']) || '' === (string) $file['name']) { + continue; + } + $uploader = new \XoopsMediaUploader( + $this->handler->getImagePath(true), + (array) ($this->handler->_allowedMimeTypes ?? []), + (int) ($this->handler->_maxFileSize ?? 1_000_000), + (int) ($this->handler->_maxWidth ?? 0), + (int) ($this->handler->_maxHeight ?? 0), + ); + if ($uploader->fetchMedia((string) $field) && $uploader->upload()) { + $object->setVar($name, $uploader->getSavedFileName()); + } else { + $object->setErrors($uploader->getErrors(false)); + } + } + } + + /** @legacy-global-accessor */ + private static function runtimeSecurity(): mixed + { + return $GLOBALS['xoopsSecurity'] ?? null; + } +} diff --git a/src/Admin/ObjectRow.php b/src/Admin/ObjectRow.php new file mode 100644 index 0000000..ebd6b0f --- /dev/null +++ b/src/Admin/ObjectRow.php @@ -0,0 +1,16 @@ + */ + protected array $columns = []; + /** @var list */ + protected array $customActions = []; + /** @var list */ + protected array $actions; + protected array $introButtons = []; + protected array $filters = []; + protected string $tableId = 'moduletools-object-table'; + protected bool $showTools = true; + protected ?array $objects = null; + + public function __construct( + protected readonly \XoopsPersistableObjectHandler $handler, + protected ?\CriteriaCompo $criteria = null, + array $actions = ['edit', 'delete'], + protected bool $userSide = false, + ) { + $this->actions = array_values(array_map(strval(...), $actions)); + } + + public function addColumn(ObjectColumn $column): void + { + $this->columns[] = $column; + } + public function addCustomAction(string $method): void + { + $this->customActions[] = $method; + } + public function addIntroButton(string $name, string $location, string $value): void + { + $this->introButtons[] = compact('name', 'location', 'value'); + } + public function addActionButton(string $operation, string|false $caption = false, string|false $text = false): void + { + $this->actions[] = $operation; + } + public function addWithSelectedActions(array $actions = []): void + { + } + public function addFilter(string $key, string $method, mixed $default = false): void + { + $this->filters[$key] = compact('method', 'default'); + } + public function setDefaultFilter(mixed $filter): void + { + } + public function setDefaultFilter2(mixed $filter): void + { + } + public function setTableId(string $id): void + { + $this->tableId = $id; + } + public function setObjects(array $objects): void + { + $this->objects = $objects; + } + public function setDefaultSort(string $sort): void + { + $this->criteria ??= new \CriteriaCompo(); + $this->criteria->setSort($sort); + } + public function setDefaultOrder(string $order): void + { + $this->criteria ??= new \CriteriaCompo(); + $this->criteria->setOrder('DESC' === strtoupper($order) ? 'DESC' : 'ASC'); + } + public function hideFilterAndLimit(): void + { + $this->showTools = false; + } + public function disableColumnsSorting(): void + { + } + public function isForUserSide(): void + { + $this->userSide = true; + } + + public function fetch(bool $debug = false): string + { + return $this->buildHtml(); + } + + public function render(bool $fetchOnly = false, bool $debug = false): ?string + { + $html = $this->buildHtml(); + if ($fetchOnly) { + return $html; + } + echo $html; + return null; + } + + /** @return list<\XoopsObject> */ + protected function fetchObjects(): array + { + if (null !== $this->objects) { + return array_values(array_filter($this->objects, static fn ($object): bool => $object instanceof \XoopsObject)); + } + return array_values($this->handler->getObjects($this->criteria)); + } + + protected function buildHtml(): string + { + $escape = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); + $html = ''; + foreach ($this->introButtons as $button) { + $html .= '

' . $escape($button['value']) . '

'; + } + $html .= ''; + foreach ($this->columns as $column) { + $caption = false !== $column->caption ? $column->caption : ($this->handler->create()->vars[$column->key]['form_caption'] ?? $column->key); + $html .= ''; + } + if ([] !== $this->actions || [] !== $this->customActions) { + $html .= ''; + } + $html .= ''; + $key = (string) $this->handler->keyName; + foreach ($this->fetchObjects() as $index => $object) { + $html .= ''; + foreach ($this->columns as $column) { + $method = $column->valueMethod; + $isCustom = false !== $method && method_exists($object, $method); + $value = $isCustom ? $object->{$method}(...(false === $column->parameters ? [] : $column->parameters)) : $object->getVar($column->key, 's'); + $html .= ''; + } + if ([] !== $this->actions || [] !== $this->customActions) { + $id = (int) $object->getVar($key, 'n'); + $script = $escape(xoops_getenv('SCRIPT_NAME')); + $links = []; + foreach ($this->actions as $action) { + $operation = 'edit' === $action ? 'mod' : ('delete' === $action ? 'del' : $action); + $links[] = '' . $escape(ucfirst($action)) . ''; + } + foreach ($this->customActions as $method) { + if (method_exists($object, $method)) { + $links[] = (string) $object->{$method}(); + } + } + $html .= ''; + } + $html .= ''; + } + $html .= '
' . $escape($caption) . '' . $escape(defined('_ACTION') ? _ACTION : 'Action') . '
' . ($isCustom ? (string) $value : $escape($value)) . '' . implode(' ', $links) . '
'; + return $html; + } +} diff --git a/src/Admin/SingleView.php b/src/Admin/SingleView.php new file mode 100644 index 0000000..e47bfcd --- /dev/null +++ b/src/Admin/SingleView.php @@ -0,0 +1,44 @@ + */ + private array $rows = []; + + public function __construct(private readonly \XoopsObject $object, private readonly bool $userSide = false, private readonly array $actions = [], private readonly bool $headerAsRow = true) + { + } + + public function addRow(ObjectRow $row): void + { + $this->rows[] = $row; + } + + public function fetch(bool $debug = false): string + { + return $this->render(true, $debug); + } + + public function render(bool $fetchOnly = false, bool $debug = false): ?string + { + $escape = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); + $html = '
'; + foreach ($this->rows as $row) { + $value = false !== $row->valueMethod && method_exists($this->object, $row->valueMethod) + ? $this->object->{$row->valueMethod}() + : $this->object->getVar($row->key, 's'); + $caption = $this->object->vars[$row->key]['form_caption'] ?? $row->key; + $html .= '
' . $escape($caption) . '
' . $escape($value) . '
'; + } + $html .= '
'; + if ($fetchOnly) { + return $html; + } + echo $html; + return null; + } +} diff --git a/src/Admin/Table.php b/src/Admin/Table.php new file mode 100644 index 0000000..d9b314d --- /dev/null +++ b/src/Admin/Table.php @@ -0,0 +1,10 @@ +' + . '' + . "\n
'; + + if ('' !== $description) { + $html .= "\n

" + . $escapedDescription . '

'; + } + + return $html . "\n"; + } + + public static function closeCollapsible(): string + { + return "
\n"; + } + + /** + * @deprecated Use {@see self::openCollapsible()} and emit its return value. + */ + public static function getCollapsableBar(string $id = '', string $title = '', string $dsc = ''): void + { + echo self::openCollapsible($id, $title, $dsc); + } + + /** + * @deprecated Use {@see self::closeCollapsible()} and emit its return value. + */ + public static function closeCollapsable(string $name = ''): void + { + echo self::closeCollapsible(); + } + + private static function normalizeId(string $id): string + { + $normalized = (string) preg_replace('/[^A-Za-z0-9_-]/', '_', $id); + + return '' !== $normalized ? $normalized : 'moduletools-section'; + } + + private static function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..53d2382 --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,91 @@ +, module_version: string, api_version: string} + */ + public static function checkRuntime( + string $minimumApiVersion = self::API_VERSION, + string $minimumModuleVersion = self::MIN_MODULE_VERSION, + bool $requireActive = false, + ): array { + unset($requireActive); + $errors = []; + + if (version_compare(self::API_VERSION, self::normalizeVersion($minimumApiVersion), '<')) { + $errors[] = sprintf( + 'ModuleTools API %s is required; API %s is available.', + $minimumApiVersion, + self::API_VERSION, + ); + } + if (version_compare(self::VERSION, self::normalizeVersion($minimumModuleVersion), '<')) { + $errors[] = sprintf( + 'ModuleTools %s is required; library %s is available.', + $minimumModuleVersion, + self::VERSION, + ); + } + + return [ + 'ok' => [] === $errors, + 'errors' => $errors, + 'module_version' => self::VERSION, + 'api_version' => self::API_VERSION, + ]; + } + + public static function assertRuntime( + string $minimumApiVersion = self::API_VERSION, + string $minimumModuleVersion = self::MIN_MODULE_VERSION, + bool $requireActive = false, + ): void { + $status = self::checkRuntime($minimumApiVersion, $minimumModuleVersion, $requireActive); + if (!$status['ok']) { + throw new \RuntimeException(self::statusMessage($status)); + } + } + + /** @param array{errors?: list} $status */ + public static function statusMessage(array $status): string + { + return implode(' ', $status['errors'] ?? []); + } + + private static function normalizeVersion(string $version): string + { + $version = trim($version); + if ('' === $version) { + return '0.0.0'; + } + if (1 === preg_match('/^\d+$/', $version) && (int) $version >= 100) { + return number_format(((int) $version) / 100, 2, '.', ''); + } + + return preg_replace('/-(alpha|beta|rc)\d*/i', '', $version) ?? $version; + } +} diff --git a/src/Common/Blocksadmin.php b/src/Common/Blocksadmin.php new file mode 100644 index 0000000..6db2b84 --- /dev/null +++ b/src/Common/Blocksadmin.php @@ -0,0 +1,745 @@ +db = $db; + $module = $this->helper->getModule(); + $this->moduleDirName = $module instanceof \XoopsModule + ? (string)$module->getVar('dirname') + : $this->helper->dirname(); + $this->moduleDirNameUpper = \mb_strtoupper($this->moduleDirName); + + \xoops_loadLanguage('admin', 'system'); + \xoops_loadLanguage('admin/blocksadmin', 'system'); + \xoops_loadLanguage('admin/groups', 'system'); + \xoops_loadLanguage('common', $this->moduleDirName); + } + + /** + * @return void + */ + public function listBlocks(): void + { + global $xoopsModule, $pathIcon16; + require_once XOOPS_ROOT_PATH . '/class/xoopslists.php'; + + + /** @var \XoopsModuleHandler $moduleHandler */ + $moduleHandler = xoops_getHandler('module'); + /** @var \XoopsMemberHandler $memberHandler */ + $memberHandler = xoops_getHandler('member'); + /** @var \XoopsGroupPermHandler $grouppermHandler */ + $grouppermHandler = xoops_getHandler('groupperm'); + $groups = $memberHandler->getGroups(); + $criteria = new \CriteriaCompo(new \Criteria('hasmain', '1')); + $criteria->add(new \Criteria('isactive', '1')); + $moduleList = $moduleHandler->getList($criteria); + $moduleList[-1] = \_AM_SYSTEM_BLOCKS_TOPPAGE; + $moduleList[0] = \_AM_SYSTEM_BLOCKS_ALLPAGES; + \ksort($moduleList); + echo "

" . \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'BADMIN') . '

'; + $scriptName = Request::getString('SCRIPT_NAME', '', 'SERVER'); + echo "
"; + echo " + + + + + + + '; + $blockArray = \XoopsBlock::getByModule($xoopsModule->mid()); + $blockCount = \count($blockArray); + $class = 'even'; + $cachetimes = [ + 0 => \_NOCACHE, + 30 => \sprintf(\_SECONDS, 30), + 60 => \_MINUTE, + 300 => \sprintf(\_MINUTES, 5), + 1800 => \sprintf(\_MINUTES, 30), + 3600 => \_HOUR, + 18000 => \sprintf(\_HOURS, 5), + 86400 => \_DAY, + 259200 => \sprintf(\_DAYS, 3), + 604800 => \_WEEK, + 2592000 => \_MONTH, + ]; + foreach ($blockArray as $i) { + $groupsPermissions = $grouppermHandler->getGroupIds('block_read', $i->getVar('bid')); + $sql = 'SELECT module_id FROM ' . $this->db->prefix('block_module_link') . ' WHERE block_id=' . $i->getVar('bid'); + $result = $this->db->query($sql); + if (!$this->db->isResultSet($result) || !($result instanceof \mysqli_result)) { + \trigger_error("Query Failed! SQL: $sql Error: " . $this->db->error(), \E_USER_ERROR); + } + $modules = []; + while (false !== ($row = (($this->db->isResultSet($result) && ($result instanceof \mysqli_result)) ? $this->db->fetchArray($result) : false))) { + $modules[] = (int)$row['module_id']; + } + + $cachetimeOptions = ''; + foreach ($cachetimes as $cachetime => $cachetimeName) { + if ($i->getVar('bcachetime') == $cachetime) { + $cachetimeOptions .= "\n"; + } else { + $cachetimeOptions .= "\n"; + } + } + + $ssel7 = ''; + $ssel6 = $ssel7; + $ssel5 = $ssel6; + $ssel4 = $ssel5; + $ssel3 = $ssel4; + $ssel2 = $ssel3; + $ssel1 = $ssel2; + $ssel0 = $ssel1; + $sel1 = $ssel0; + $sel0 = $sel1; + if (1 === $i->getVar('visible')) { + $sel1 = ' checked'; + } else { + $sel0 = ' checked'; + } + if (\XOOPS_SIDEBLOCK_LEFT === $i->getVar('side')) { + $ssel0 = ' checked'; + } elseif (\XOOPS_SIDEBLOCK_RIGHT === $i->getVar('side')) { + $ssel1 = ' checked'; + } elseif (\XOOPS_CENTERBLOCK_LEFT === $i->getVar('side')) { + $ssel2 = ' checked'; + } elseif (\XOOPS_CENTERBLOCK_RIGHT === $i->getVar('side')) { + $ssel4 = ' checked'; + } elseif (\XOOPS_CENTERBLOCK_CENTER === $i->getVar('side')) { + $ssel3 = ' checked'; + } elseif (\XOOPS_CENTERBLOCK_BOTTOMLEFT === $i->getVar('side')) { + $ssel5 = ' checked'; + } elseif (\XOOPS_CENTERBLOCK_BOTTOMRIGHT === $i->getVar('side')) { + $ssel6 = ' checked'; + } elseif (\XOOPS_CENTERBLOCK_BOTTOM === $i->getVar('side')) { + $ssel7 = ' checked'; + } + if ('' === $i->getVar('title')) { + $title = ' '; + } else { + // A stored _MI_* token resolves through the module's modinfo for display; + // the hidden oldtitle below keeps the raw value so orderBlock() can tell + // an unchanged resolved label from a real edit. + $title = \method_exists($i, 'title') ? (string) $i->title('s') : $i->getVar('title'); + } + echo " + + + '; + + echo "'; + + echo "'; + + // Cache lifetime + echo ''; + + // Actions + + echo " + "; + $class = ('even' === $class) ? 'odd' : 'even'; + } + echo " +
" . \_AM_SYSTEM_BLOCKS_TITLE . "" . \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'SIDE') . '
' . \_LEFT . '-' . \_CENTER . '-' . \_RIGHT . "
" . \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'WEIGHT') . "" . \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'VISIBLE') . "" . \_AM_SYSTEM_BLOCKS_VISIBLEIN . "" . \_AM_SYSTEM_ADGS . "" . \_AM_SYSTEM_BLOCKS_BCACHETIME . "" . \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'ACTION') . '
+ + + +
+
+ +
+
+
+ + + +
+
" . \_YES . " " . \_NO . ' + + " . \_EDIT . " + " . \_CLONE . ""; + // if ('S' !== $i->getVar('block_type') && 'M' !== $i->getVar('block_type')) { + // echo " getVar('bid') . "'>" . _DELETE . " + // "; + // } + + // if ('S' !== $i->getVar('block_type') && 'M' !== $i->getVar('block_type')) { + if (!\in_array($i->getVar('block_type'), ['M', 'S'])) { + echo "  + " . \_DELETE . " + "; + } + echo " + + + + + + + +
+ " . self::runtimeGlobal('xoopsSecurity')->getTokenHTML() . " + +
+
+

"; + } + + /** + * @param int $bid + */ + public function deleteBlock(int $bid): void + { + // \xoops_cp_header(); + + + + $myblock = new \XoopsBlock($bid); + + $sql = \sprintf('DELETE FROM %s WHERE bid = %u', $this->db->prefix('newblocks'), $bid); + $result = $this->db->exec($sql); + if (!$result) { + \trigger_error("Query Failed! SQL: $sql Error: " . $this->db->error(), \E_USER_ERROR); + } + $sql = \sprintf('DELETE FROM %s WHERE block_id = %u', $this->db->prefix('block_module_link'), $bid); + $result = $this->db->exec($sql); + if (!$result) { + \trigger_error("Query Failed! SQL: $sql Error: " . $this->db->error(), \E_USER_ERROR); + } + + $this->helper->redirect('admin/blocksadmin.php?op=list', 1, _AM_DBUPDATED); + } + + /** + * @param int $bid + */ + public function cloneBlock(int $bid): void + { + //require __DIR__ . '/admin_header.php'; + // \xoops_cp_header(); + + + + $myblock = new \XoopsBlock($bid); + $sql = 'SELECT module_id FROM ' . $this->db->prefix('block_module_link') . ' WHERE block_id=' . $bid; + $result = $this->db->query($sql); + if (!$this->db->isResultSet($result) || !($result instanceof \mysqli_result)) { + \trigger_error("Query Failed! SQL: $sql Error: " . $this->db->error(), \E_USER_ERROR); + } + $modules = []; + while (false !== ($row = (($this->db->isResultSet($result) && ($result instanceof \mysqli_result)) ? $this->db->fetchArray($result) : false))) { + $modules[] = (int)$row['module_id']; + } + + $isCustom = \in_array($myblock->getVar('block_type'), ['C', 'E']); + $block = [ + 'title' => $myblock->getVar('title') . ' Clone', + 'form_title' => \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'BLOCKS_CLONEBLOCK'), + 'name' => $myblock->getVar('name'), + 'side' => $myblock->getVar('side'), + 'weight' => $myblock->getVar('weight'), + 'visible' => $myblock->getVar('visible'), + 'content' => $myblock->getVar('content', 'N'), + 'modules' => $modules, + 'is_custom' => $isCustom, + 'ctype' => $myblock->getVar('c_type'), + 'bcachetime' => $myblock->getVar('bcachetime'), + 'op' => 'clone_ok', + 'bid' => $myblock->getVar('bid'), + 'edit_form' => $myblock->getOptions(), + 'template' => $myblock->getVar('template'), + 'options' => $myblock->getVar('options'), + ]; + echo '' . \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'BADMIN') . ' »» ' . \_AM_SYSTEM_BLOCKS_CLONEBLOCK . '

'; + // $form = new Blockform(); + // $form->render(); + + $this->render($block); + // xoops_cp_footer(); + // require_once __DIR__ . '/admin_footer.php'; + // exit(); + } + + /** + * @param int $bid + * @param string $bside + * @param string $bweight + * @param string $bvisible + * @param string $bcachetime + * @param array|null $bmodule + * @param array|null $options + * @param array|null $groups + */ + public function isBlockCloned(int $bid, string $bside, string $bweight, string $bvisible, string $bcachetime, ?array $bmodule, ?array $options, ?array $groups): void + { + + + $block = new \XoopsBlock($bid); + /** @var \XoopsBlock $clone */ + $clone = $block->xoopsClone(); + if (empty($bmodule)) { + // \xoops_cp_header(); + \xoops_error(\sprintf(\_AM_NOTSELNG, _AM_VISIBLEIN)); + \xoops_cp_footer(); + exit(); + } + $clone->setVar('side', $bside); + $clone->setVar('weight', $bweight); + $clone->setVar('visible', $bvisible); + //$clone->setVar('content', $_POST['bcontent']); + $clone->setVar('title', Request::getString('btitle', '', 'POST')); + $clone->setVar('bcachetime', $bcachetime); + if (\is_array($options) && (\count($options) > 0)) { + $options = \implode('|', $options); + $clone->setVar('options', $options); + } + $clone->setVar('bid', 0); + if (\in_array($block->getVar('block_type'), ['C', 'E'])) { + $clone->setVar('block_type', 'E'); + } else { + $clone->setVar('block_type', 'D'); + } + $newid = 0; + if ($clone->store()) { + $newid = $clone->id(); //get the id of the cloned block + } + if (!$newid) { + // \xoops_cp_header(); + $clone->getHtmlErrors(); + \xoops_cp_footer(); + exit(); + } + if ('' !== $clone->getVar('template')) { + /** @var \XoopsTplfileHandler $tplfileHandler */ + $tplfileHandler = xoops_getHandler('tplfile'); + $config = self::runtimeGlobal('xoopsConfig'); + $btemplate = $tplfileHandler->find($config['template_set'], 'block', (string)$bid); + if (\count($btemplate) > 0) { + $tplclone = $btemplate[0]->xoopsClone(); + $tplclone->setVar('tpl_id', 0); + $tplclone->setVar('tpl_refid', $newid); + $tplfileHandler->insert($tplclone); + } + } + + foreach ($bmodule as $bmid) { + $sql = 'INSERT INTO ' . $this->db->prefix('block_module_link') . ' (block_id, module_id) VALUES (' . $newid . ', ' . $bmid . ')'; + $this->db->exec($sql); + } + //$groups = &$GLOBALS['xoopsUser']->getGroups(); + foreach ($groups as $iValue) { + $sql = 'INSERT INTO ' . $this->db->prefix('group_permission') . ' (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES (' . $iValue . ', ' . $newid . ", 1, 'block_read')"; + $this->db->exec($sql); + } + $this->helper->redirect('admin/blocksadmin.php?op=list', 1, _AM_DBUPDATED); + } + + /** + * @param int|string $bid + * @param int|string $title + * @param int|string $weight + * @param int|string $visible + * @param int|string $side + * @param int|string $bcachetime + * @param array|null $bmodule + */ + public function setOrder(int|string $bid, int|string $title, int|string $weight, int|string $visible, int|string $side, int|string $bcachetime, ?array $bmodule = null): void + { + $myblock = new \XoopsBlock((int)$bid); + $myblock->setVar('title', (string)$title); + $myblock->setVar('weight', (int)$weight); + $myblock->setVar('visible', (int)$visible); + $myblock->setVar('side', (int)$side); + $myblock->setVar('bcachetime', (int)$bcachetime); + $myblock->store(); + // /** @var \XoopsBlockHandler $blockHandler */ + // $blockHandler = \xoops_getHandler('block'); + // return $blockHandler->insert($myblock); + } + + /** + * @param int $bid + * @return void + */ + public function editBlock(int $bid): void + { + // require_once \dirname(__DIR__,2) . '/admin/admin_header.php'; + // \xoops_cp_header(); + + // mpu_adm_menu(); + $myblock = new \XoopsBlock($bid); + $sql = 'SELECT module_id FROM ' . $this->db->prefix('block_module_link') . ' WHERE block_id=' . $bid; + $result = $this->db->query($sql); + if (!$this->db->isResultSet($result) || !($result instanceof \mysqli_result)) { + \trigger_error("Query Failed! SQL: $sql Error: " . $this->db->error(), \E_USER_ERROR); + } + $modules = []; + while (false !== ($row = (($this->db->isResultSet($result) && ($result instanceof \mysqli_result)) ? $this->db->fetchArray($result) : false))) { + $modules[] = (int)$row['module_id']; + } + + $isCustom = \in_array($myblock->getVar('block_type'), ['C', 'E']); + $block = [ + 'title' => $myblock->getVar('title'), + 'form_title' => \_AM_SYSTEM_BLOCKS_EDITBLOCK, + // 'name' => $myblock->getVar('name'), + 'side' => $myblock->getVar('side'), + 'weight' => $myblock->getVar('weight'), + 'visible' => $myblock->getVar('visible'), + 'content' => $myblock->getVar('content', 'N'), + 'modules' => $modules, + 'is_custom' => $isCustom, + 'ctype' => $myblock->getVar('c_type'), + 'bcachetime' => $myblock->getVar('bcachetime'), + 'op' => 'edit_ok', + 'bid' => $myblock->getVar('bid'), + 'edit_form' => $myblock->getOptions(), + 'template' => $myblock->getVar('template'), + 'options' => $myblock->getVar('options'), + ]; + echo '' . \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'BADMIN') . ' »» ' . \_AM_SYSTEM_BLOCKS_EDITBLOCK . '

'; + + $this->render($block); + } + + /** + * @param int $bid + * @param string $btitle + * @param string $bside + * @param string $bweight + * @param string $bvisible + * @param string $bcachetime + * @param array|null $bmodule + * @param array|null $options + * @param array|null $groups + */ + public function updateBlock(int $bid, string $btitle, string $bside, string $bweight, string $bvisible, string $bcachetime, ?array $bmodule, ?array $options, ?array $groups): void + { + $myblock = new \XoopsBlock($bid); + $myblock->setVar('title', $btitle); + $myblock->setVar('weight', $bweight); + $myblock->setVar('visible', $bvisible); + $myblock->setVar('side', $bside); + $myblock->setVar('bcachetime', $bcachetime); + //update block options + if (isset($options)) { + $optionsCount = \count($options); + if ($optionsCount > 0) { + //Convert array values to comma-separated + foreach ($options as $i => $iValue) { + if (\is_array($iValue)) { + $options[$i] = \implode(',', $iValue); + } + } + $options = \implode('|', $options); + $myblock->setVar('options', $options); + } + } + $myblock->store(); + // /** @var \XoopsBlockHandler $blockHandler */ + // $blockHandler = \xoops_getHandler('block'); + // $blockHandler->insert($myblock); + + if (!empty($bmodule) && \count($bmodule) > 0) { + $sql = \sprintf('DELETE FROM `%s` WHERE block_id = %u', $this->db->prefix('block_module_link'), $bid); + $this->db->exec($sql); + if (\in_array(0, $bmodule)) { + $sql = \sprintf('INSERT INTO `%s` (block_id, module_id) VALUES (%u, %d)', $this->db->prefix('block_module_link'), $bid, 0); + $this->db->exec($sql); + } else { + foreach ($bmodule as $bmid) { + $sql = \sprintf('INSERT INTO `%s` (block_id, module_id) VALUES (%u, %d)', $this->db->prefix('block_module_link'), $bid, (int)$bmid); + $this->db->exec($sql); + } + } + } + $sql = \sprintf('DELETE FROM `%s` WHERE gperm_itemid = %u', $this->db->prefix('group_permission'), $bid); + $this->db->exec($sql); + if (!empty($groups)) { + foreach ($groups as $grp) { + $sql = \sprintf("INSERT INTO `%s` (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES (%u, %u, 1, 'block_read')", $this->db->prefix('group_permission'), $grp, $bid); + $this->db->exec($sql); + } + } + $this->helper->redirect('admin/blocksadmin.php', 1, \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'UPDATE_SUCCESS')); + } + + /** + * @param array $bid + * @param array $oldtitle + * @param array $oldside + * @param array $oldweight + * @param array $oldvisible + * @param array $oldgroups + * @param array $oldbcachetime + * @param array $oldbmodule + * @param array $title + * @param array $weight + * @param array $visible + * @param array $side + * @param array $bcachetime + * @param array $groups + * @param array $bmodule + */ + public function orderBlock( + array $bid, + array $oldtitle, + array $oldside, + array $oldweight, + array $oldvisible, + array $oldgroups, + array $oldbcachetime, + array $oldbmodule, + array $title, + array $weight, + array $visible, + array $side, + array $bcachetime, + array $groups, + array $bmodule + ): void { + $security = self::runtimeGlobal('xoopsSecurity'); + if (!$security->check()) { + \redirect_header(Request::getString('SCRIPT_NAME', '', 'SERVER'), 3, \implode('
', $security->getErrors())); + } + foreach (\array_keys($bid) as $i) { + $blockId = (int)$bid[$i]; + if ($blockId <= 0) { + continue; + } + + $this->setOrder( + $blockId, + $this->preserveTitleToken((string) ($oldtitle[$i] ?? ''), (string) ($title[$i] ?? '')), + $weight[$i] ?? 0, + $visible[$i] ?? 0, + $side[$i] ?? 0, + $bcachetime[$i] ?? 0, + $bmodule[$i] ?? [] + ); + + if (!empty($bmodule[$i]) && \is_array($bmodule[$i])) { + $sql = \sprintf('DELETE FROM `%s` WHERE block_id = %u', $this->db->prefix('block_module_link'), $blockId); + $this->db->exec($sql); + if (\in_array(0, $bmodule[$i], true)) { + $sql = \sprintf('INSERT INTO `%s` (block_id, module_id) VALUES (%u, %d)', $this->db->prefix('block_module_link'), $blockId, 0); + $this->db->exec($sql); + } else { + foreach ($bmodule[$i] as $bmid) { + $sql = \sprintf('INSERT INTO `%s` (block_id, module_id) VALUES (%u, %d)', $this->db->prefix('block_module_link'), $blockId, (int)$bmid); + $this->db->exec($sql); + } + } + } + $sql = \sprintf('DELETE FROM `%s` WHERE gperm_itemid = %u AND gperm_name = %s', $this->db->prefix('group_permission'), $blockId, $this->db->quote('block_read')); + $this->db->exec($sql); + if (!empty($groups[$i]) && \is_array($groups[$i])) { + foreach ($groups[$i] as $grp) { + $sql = \sprintf("INSERT INTO `%s` (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES (%u, %u, 1, 'block_read')", $this->db->prefix('group_permission'), $grp, $blockId); + $this->db->exec($sql); + } + } + } + + $this->helper->redirect('admin/blocksadmin.php', 1, \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'UPDATE_SUCCESS')); + } + + /** + * @param array|null $block + * @return void + */ + public function render(?array $block = null): void + { + \xoops_load('XoopsFormLoader'); + \xoops_loadLanguage('common', $this->moduleDirName); + + $form = new \XoopsThemeForm($block['form_title'], 'blockform', 'blocksadmin.php', 'post', true); + if (isset($block['name'])) { + $form->addElement(new \XoopsFormLabel(\_AM_SYSTEM_BLOCKS_NAME, $block['name'])); + } + $sideSelect = new \XoopsFormSelect(\_AM_SYSTEM_BLOCKS_TYPE, 'bside', $block['side']); + $sideSelect->addOptionArray([ + 0 => \_AM_SYSTEM_BLOCKS_SBLEFT, + 1 => \_AM_SYSTEM_BLOCKS_SBRIGHT, + 3 => \_AM_SYSTEM_BLOCKS_CBLEFT, + 4 => \_AM_SYSTEM_BLOCKS_CBRIGHT, + 5 => \_AM_SYSTEM_BLOCKS_CBCENTER, + 7 => \_AM_SYSTEM_BLOCKS_CBBOTTOMLEFT, + 8 => \_AM_SYSTEM_BLOCKS_CBBOTTOMRIGHT, + 9 => \_AM_SYSTEM_BLOCKS_CBBOTTOM, + ]); + $form->addElement($sideSelect); + $form->addElement(new \XoopsFormText(\constant('_CO_' . $this->moduleDirNameUpper . '_' . 'WEIGHT'), 'bweight', 2, 5, $block['weight'])); + $form->addElement(new \XoopsFormRadioYN(\constant('_CO_' . $this->moduleDirNameUpper . '_' . 'VISIBLE'), 'bvisible', $block['visible'])); + $modSelect = new \XoopsFormSelect(\constant('_CO_' . $this->moduleDirNameUpper . '_' . 'VISIBLEIN'), 'bmodule', $block['modules'], 5, true); + /** @var \XoopsModuleHandler $moduleHandler */ + $moduleHandler = xoops_getHandler('module'); + $criteria = new \CriteriaCompo(new \Criteria('hasmain', '1')); + $criteria->add(new \Criteria('isactive', '1')); + $moduleList = $moduleHandler->getList($criteria); + $moduleList[-1] = \_AM_SYSTEM_BLOCKS_TOPPAGE; + $moduleList[0] = \_AM_SYSTEM_BLOCKS_ALLPAGES; + \ksort($moduleList); + $modSelect->addOptionArray($moduleList); + $form->addElement($modSelect); + $form->addElement(new \XoopsFormText(\_AM_SYSTEM_BLOCKS_TITLE, 'btitle', 50, 255, $block['title']), false); + if ($block['is_custom']) { + $textarea = new \XoopsFormDhtmlTextArea(\_AM_SYSTEM_BLOCKS_CONTENT, 'bcontent', $block['content'], 15, 70); + $textarea->setDescription('' . \_AM_SYSTEM_BLOCKS_USEFULTAGS . '
' . \sprintf(_AM_BLOCKTAG1, '{X_SITEURL}', XOOPS_URL . '/') . ''); + $form->addElement($textarea, true); + $ctypeSelect = new \XoopsFormSelect(\_AM_SYSTEM_BLOCKS_CTYPE, 'bctype', $block['ctype']); + $ctypeSelect->addOptionArray([ + 'H' => \_AM_SYSTEM_BLOCKS_HTML, + 'P' => \_AM_SYSTEM_BLOCKS_PHP, + 'S' => \_AM_SYSTEM_BLOCKS_AFWSMILE, + 'T' => \_AM_SYSTEM_BLOCKS_AFNOSMILE, + ]); + $form->addElement($ctypeSelect); + } else { + if ('' !== $block['template']) { + /** @var \XoopsTplfileHandler $tplfileHandler */ + $tplfileHandler = xoops_getHandler('tplfile'); + $config = self::runtimeGlobal('xoopsConfig'); + $btemplate = $tplfileHandler->find($config['template_set'], 'block', $block['bid']); + if (\count($btemplate) > 0) { + $form->addElement(new \XoopsFormLabel(\_AM_SYSTEM_BLOCKS_CONTENT, '' . \_AM_SYSTEM_BLOCKS_EDITTPL . '')); + } else { + $btemplate2 = $tplfileHandler->find('default', 'block', $block['bid']); + if (\count($btemplate2) > 0) { + $form->addElement(new \XoopsFormLabel(\_AM_SYSTEM_BLOCKS_CONTENT, '' . \_AM_SYSTEM_BLOCKS_EDITTPL . '')); + } + } + } + if (false !== $block['edit_form']) { + $form->addElement(new \XoopsFormLabel(\_AM_SYSTEM_BLOCKS_OPTIONS, $block['edit_form'])); + } + } + $cache_select = new \XoopsFormSelect(\_AM_SYSTEM_BLOCKS_BCACHETIME, 'bcachetime', $block['bcachetime']); + $cache_select->addOptionArray([ + 0 => \_NOCACHE, + 30 => \sprintf(\_SECONDS, 30), + 60 => \_MINUTE, + 300 => \sprintf(\_MINUTES, 5), + 1800 => \sprintf(\_MINUTES, 30), + 3600 => \_HOUR, + 18000 => \sprintf(\_HOURS, 5), + 86400 => \_DAY, + 259200 => \sprintf(\_DAYS, 3), + 604800 => \_WEEK, + 2592000 => \_MONTH, + ]); + $form->addElement($cache_select); + + /** @var \XoopsGroupPermHandler $grouppermHandler */ + $grouppermHandler = xoops_getHandler('groupperm'); + $groups = $grouppermHandler->getGroupIds('block_read', $block['bid']); + + $form->addElement(new \XoopsFormSelectGroup(\_AM_SYSTEM_BLOCKS_GROUP, 'groups', true, $groups, 5, true)); + + if (isset($block['bid'])) { + $form->addElement(new \XoopsFormHidden('bid', $block['bid'])); + } + $form->addElement(new \XoopsFormHidden('op', $block['op'])); + $form->addElement(new \XoopsFormHidden('fct', 'blocksadmin')); + $buttonTray = new \XoopsFormElementTray('', ' '); + if ($block['is_custom']) { + $buttonTray->addElement(new \XoopsFormButton('', 'previewblock', \_PREVIEW, 'submit')); + } + + //Submit buttons + $buttonTray = new \XoopsFormElementTray('', ''); + $submitButton = new \XoopsFormButton('', 'submitblock', \_SUBMIT, 'submit'); + $buttonTray->addElement($submitButton); + + $cancelButton = new \XoopsFormButton('', '', \_CANCEL, 'button'); + $cancelButton->setExtra('onclick="history.go(-1)"'); + $buttonTray->addElement($cancelButton); + + $form->addElement($buttonTray); + $form->display(); + } + + /** + * The list shows a stored `_MI_*` token resolved; if the admin submits that + * resolved label unchanged, keep the token so the block title stays translatable. + */ + private function preserveTitleToken(string $storedTitle, string $submittedTitle): string + { + if ( + $submittedTitle === $storedTitle + || !\class_exists(\Xmf\I18n\LabelResolver::class) + || !\Xmf\I18n\LabelResolver::isToken($storedTitle) + ) { + return $submittedTitle; + } + + return $submittedTitle === (string) \Xmf\I18n\LabelResolver::resolve($storedTitle, $this->moduleDirName) + ? $storedTitle + : $submittedTitle; + } + + /** @legacy-global-accessor */ + private static function runtimeGlobal(string $name): mixed + { + return $GLOBALS[$name] ?? null; + } +} diff --git a/src/Common/Breadcrumb.php b/src/Common/Breadcrumb.php new file mode 100644 index 0000000..9de6525 --- /dev/null +++ b/src/Common/Breadcrumb.php @@ -0,0 +1,96 @@ + + * + * Example: + * $breadcrumb = new Breadcrumb(); + * $breadcrumb->addLink( 'bread 1', 'index1.php' ); + * $breadcrumb->addLink( 'bread 2', '' ); + * $breadcrumb->addLink( 'bread 3', 'index3.php' ); + * echo $breadcrumb->render(); + */ + +/** + * Class Breadcrumb + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + */ +class Breadcrumb +{ + public string $dirname; + private array $bread = []; + private readonly string $template; + + public function __construct(?string $dirname = null, ?string $template = null) + { + $this->dirname = $dirname ? basename($dirname) : \basename(\dirname(__DIR__, 2)); + $this->template = $template ?: $this->dirname . '_common_breadcrumb.tpl'; + } + + /** + * Add link to breadcrumb + * + * @param string $title + * @param string $link + */ + public function addLink($title = '', $link = ''): void + { + $this->bread[] = [ + 'link' => $link, + 'title' => $title, + ]; + } + + /** + * Render BreadCrumb + * @return array|bool|mixed|string|string[]|void + */ + public function render() + { + $theme = self::runtimeGlobal('xoTheme'); + $xoops = self::runtimeGlobal('xoops'); + if (!is_object($theme)) { + require $xoops->path('class/theme.php'); + self::setRuntimeGlobal('xoTheme', new \xos_opal_Theme()); + } + + require $xoops->path('class/template.php'); + $breadcrumbTpl = new \XoopsTpl(); + $breadcrumbTpl->assign('breadcrumb', $this->bread); + $html = $breadcrumbTpl->fetch('db:' . $this->template); + unset($breadcrumbTpl); + + return $html; + } + + /** @legacy-global-accessor */ + private static function runtimeGlobal(string $name): mixed + { + return $GLOBALS[$name] ?? null; + } + + /** @legacy-global-accessor */ + private static function setRuntimeGlobal(string $name, mixed $value): void + { + $GLOBALS[$name] = $value; + } +} diff --git a/src/Common/Cloner.php b/src/Common/Cloner.php new file mode 100644 index 0000000..21ec9d0 --- /dev/null +++ b/src/Common/Cloner.php @@ -0,0 +1,199 @@ +, XOOPS Development Team + * @since 1.5.0 + */ +final class Cloner +{ + /** Binary extensions copied verbatim instead of text-replaced. */ + private const array BINARY_EXTENSIONS = ['jpeg', 'jpg', 'gif', 'png', 'webp', 'ico', 'zip', 'ttf', 'woff', 'woff2', 'pdf']; + + /** Top-level entries never copied into a clone. */ + private const array SKIPPED_ENTRIES = ['node_modules', '.git', '.idea']; + + public static function isValidDirname(string $dirname): bool + { + return 1 === \preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D', $dirname); + } + + /** + * Copy `$sourcePath` to a sibling directory named `$newDirname`. + * + * @throws \InvalidArgumentException on a bad name, missing source or existing target + * @throws \RuntimeException when a directory cannot be created + */ + public static function clone(string $sourcePath, string $newDirname): string + { + $sourcePath = \rtrim(\str_replace('\\', '/', $sourcePath), '/'); + $oldDirname = \basename($sourcePath); + $newDirname = \mb_strtolower($newDirname); + + if (!self::isValidDirname($newDirname)) { + throw new \InvalidArgumentException('Invalid module dirname: ' . $newDirname); + } + if (!\is_dir($sourcePath)) { + throw new \InvalidArgumentException('Source module does not exist: ' . $sourcePath); + } + $targetPath = \dirname($sourcePath) . '/' . $newDirname; + if (\file_exists($targetPath)) { + throw new \InvalidArgumentException('Target already exists: ' . $targetPath); + } + + $patterns = [ + \mb_strtolower($oldDirname) => $newDirname, + \mb_strtoupper($oldDirname) => \mb_strtoupper($newDirname), + \ucfirst(\mb_strtolower($oldDirname)) => \ucfirst($newDirname), + ]; + self::copyTree($sourcePath, $targetPath, \array_keys($patterns), \array_values($patterns), true); + + return $targetPath; + } + + /** + * Write the Ucfirst dirname onto `assets/images/logoModule.png` of a module. + * Uses the module's own `assets/images/VeraBd.ttf` or the package copy. + */ + public static function createLogo(string $modulePath, ?string $label = null): bool + { + $modulePath = \rtrim(\str_replace('\\', '/', $modulePath), '/'); + $logo = $modulePath . '/assets/images/logoModule.png'; + $font = $modulePath . '/assets/images/VeraBd.ttf'; + if (!\is_file($font)) { + $font = \dirname(__DIR__, 2) . '/resources/fonts/VeraBd.ttf'; + } + if (!\extension_loaded('gd') || !\function_exists('imagefttext') || !\is_file($logo) || !\is_file($font)) { + return false; + } + $image = @\imagecreatefrompng($logo); + if (false === $image) { + return false; + } + $label ??= \ucfirst(\basename($modulePath)); + + \imagealphablending($image, false); + \imagesavealpha($image, true); + $grey = \imagecolorallocate($image, 237, 237, 237); + \imagefilledrectangle($image, 5, 35, 85, 46, $grey); + $black = \imagecolorallocate($image, 0, 0, 0); + $x = (int) ((80 - \mb_strlen($label) * 6.5) / 2); + \imagefttext($image, 8.5, 0, \max(2, $x), 45, $black, $font, $label); + $ok = \imagepng($image, $logo); + + return $ok; + } + + /** + * Shared body of a module's `admin/clone.php`: renders the form, or on POST + * clones the helper's module and returns the result message. + * Call between `xoops_cp_header()` and the admin footer. + */ + public static function handleAdminRequest(\Xmf\Module\Helper $helper): string + { + PackageLanguage::load('common', (string) (self::runtimeGlobal('xoopsConfig')['language'] ?? 'english')); + $dirname = $helper->dirname(); + + if ('submit' !== Request::getString('op', '', 'POST')) { + \xoops_load('XoopsFormLoader'); + $moduleName = \is_object($helper->getModule()) ? (string) $helper->getModule()->getVar('name', 'E') : $dirname; + $form = new \XoopsThemeForm(\sprintf(\_CO_MTOOLS_CLONE_TITLE, $moduleName), 'clone', 'clone.php', 'post', true); + $name = new \XoopsFormText(\_CO_MTOOLS_CLONE_NAME, 'clone', 20, 64, ''); + $name->setDescription(\_CO_MTOOLS_CLONE_NAME_DSC); + $form->addElement($name, true); + $form->addElement(new \XoopsFormHidden('op', 'submit')); + $form->addElement(new \XoopsFormButton('', '', \_SUBMIT, 'submit')); + + return '

' . \_CO_MTOOLS_CLONE_DSC . '

' . $form->render(); + } + + $security = self::runtimeGlobal('xoopsSecurity'); + if (\is_object($security) && !$security->check()) { + \redirect_header('clone.php', 3, \implode('
', $security->getErrors())); + } + $clone = \mb_strtolower(Request::getString('clone', '', 'POST')); + $safe = \htmlspecialchars($clone, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + if (!self::isValidDirname($clone)) { + \redirect_header('clone.php', 3, \_CO_MTOOLS_CLONE_INVALIDNAME); + } + $modulesRoot = \dirname($helper->path()); + if (\file_exists($modulesRoot . '/' . $clone)) { + \redirect_header('clone.php', 3, \_CO_MTOOLS_CLONE_EXISTS); + } + + try { + $targetPath = self::clone($helper->path(), $clone); + } catch (\Throwable) { + return \_CO_MTOOLS_CLONE_FAIL; + } + $message = \sprintf( + \_CO_MTOOLS_CLONE_CONGRAT, + "" . \ucfirst($safe) . '', + ) . "
\n"; + if (!self::createLogo($targetPath)) { + $message .= \_CO_MTOOLS_CLONE_IMAGEFAIL; + } + + return $message; + } + + /** @param list $search @param list $replace */ + private static function copyTree(string $source, string $target, array $search, array $replace, bool $topLevel): void + { + if (!\mkdir($target) && !\is_dir($target)) { + throw new \RuntimeException(\sprintf('Directory "%s" was not created', $target)); + } + $entries = \scandir($source, \SCANDIR_SORT_ASCENDING); + if (false === $entries) { + throw new \RuntimeException(\sprintf('Directory "%s" could not be read', $source)); + } + foreach ($entries as $entry) { + if ('.' === $entry || '..' === $entry || ($topLevel && \in_array($entry, self::SKIPPED_ENTRIES, true))) { + continue; + } + $from = $source . '/' . $entry; + $to = $target . '/' . \str_replace($search, $replace, $entry); + if (\is_dir($from)) { + self::copyTree($from, $to, $search, $replace, false); + continue; + } + $extension = \mb_strtolower(\pathinfo($from, \PATHINFO_EXTENSION)); + if (\in_array($extension, self::BINARY_EXTENSIONS, true)) { + if (!\copy($from, $to)) { + throw new \RuntimeException(\sprintf('File "%s" could not be copied to "%s"', $from, $to)); + } + continue; + } + $content = \file_get_contents($from); + if (false === $content || false === \file_put_contents($to, \str_replace($search, $replace, $content))) { + throw new \RuntimeException(\sprintf('File "%s" could not be copied to "%s"', $from, $to)); + } + } + } + + /** @legacy-global-accessor */ + private static function runtimeGlobal(string $name): mixed + { + return $GLOBALS[$name] ?? null; + } +} diff --git a/src/Common/Configurator.php b/src/Common/Configurator.php new file mode 100644 index 0000000..150dfb1 --- /dev/null +++ b/src/Common/Configurator.php @@ -0,0 +1,197 @@ + */ + public array $paths = []; + /** @var list */ + public array $uploadFolders = []; + /** @var list */ + public array $copyBlankFiles = []; + /** @var list */ + public array $copyTestFolders = []; + /** @var list */ + public array $templateFolders = []; + /** @var list */ + public array $oldFiles = []; + /** @var list */ + public array $oldFolders = []; + /** @var array */ + public array $renameTables = []; + /** @var array */ + public array $renameColumns = []; + /** @var array */ + public array $moduleStats = []; + public string $modCopyright; + /** @var array */ + public array $icons = []; + private readonly string $baseDir; + private readonly ModuleConfig $moduleConfig; + + /** + * Named constructor: build a Configurator for the CONSUMER module from its Helper. + * + * Prefer this over `new Configurator(...)` — it makes the consumer's base directory + * explicit and avoids the no-argument footgun (see the constructor). + * + * @since 1.2.0 + */ + public static function forModule(\Xmf\Module\Helper $helper): self + { + return new self($helper->path()); + } + + /** + * Configurator constructor. + * + * The base directory MUST be supplied (e.g. `$helper->path()` or {@see self::forModule()}). + * A missing/empty directory throws rather than silently falling back to mtools' OWN + * directory — that fallback used to load mtools' config for the consumer, a silent bug. + * + * @param string|null $dir the CONSUMER module base directory + * + * @throws \InvalidArgumentException when no base directory is given + */ + public function __construct(?string $dir = null) + { + $dir = rtrim((string)$dir, '/\\'); + if ('' === $dir) { + throw new \InvalidArgumentException( + 'Configurator requires the consumer module base directory; ' + . 'pass it explicitly (e.g. Configurator::forModule($helper) or new Configurator($helper->path())).' + ); + } + $this->baseDir = $dir; + + $configFile = $this->baseDir . '/config/config.php'; + if (!\is_file($configFile)) { + throw new \RuntimeException('Missing config file: ' . $configFile); + } + $config = require $configFile; + if (\is_array($config)) { + // Older modules commonly return an associative array. Normalize it + // at the library boundary so consumers can adopt ModuleTools without + // rewriting an otherwise valid module configuration file. + $config = (object) $config; + } + if (!\is_object($config)) { + throw new \RuntimeException( + \sprintf( + 'Invalid config format in %s: expected array or object, got %s', + $configFile, + \gettype($config) + ) + ); + } + + // Typed, null-safe view of config/config.php (the contract for new code). + $this->moduleConfig = ModuleConfig::fromObject($config); + + // Back-compat: mirror the typed config into the public properties consumers read. + $this->name = $this->moduleConfig->name; + $this->uploadFolders = $this->moduleConfig->uploadFolders; + $this->copyBlankFiles = $this->moduleConfig->copyBlankFiles; + $this->copyTestFolders = $this->moduleConfig->copyTestFolders; + $this->templateFolders = $this->moduleConfig->templateFolders; + $this->oldFiles = $this->moduleConfig->oldFiles; + $this->oldFolders = $this->moduleConfig->oldFolders; + $this->renameTables = $this->moduleConfig->renameTables; + $this->renameColumns = $this->moduleConfig->renameColumns; + $this->moduleStats = $this->moduleConfig->moduleStats; + $this->paths = $this->moduleConfig->paths; + $this->modCopyright = $this->moduleConfig->modCopyright; + + $iconsFile = $this->baseDir . '/config/icons.php'; + $pathsFile = $this->baseDir . '/config/paths.php'; + if (\is_file($iconsFile)) { + $this->icons = self::loadMap($iconsFile, 'icons'); + } + if (\is_file($pathsFile)) { + $this->paths = \array_replace($this->paths, self::loadMap($pathsFile, 'paths')); + } + } + + public function getPath(string $key): ?string + { + $value = $this->paths[$key] ?? null; + + return is_string($value) ? $value : null; + } + + public function baseDir(): string + { + return $this->baseDir; + } + + /** + * Load a config map while accepting both the current flat format and the + * legacy configurator object (`['paths' => [...]]` / `['icons' => [...]]`). + * + * @return array + */ + private static function loadMap(string $file, string $legacyProperty): array + { + $value = require $file; + if (\is_object($value)) { + $value = \get_object_vars($value); + } + if (!\is_array($value)) { + throw new \RuntimeException( + \sprintf('Invalid %s config format in %s: expected array or object, got %s', $legacyProperty, $file, \gettype($value)) + ); + } + + if (\array_key_exists($legacyProperty, $value)) { + $value = $value[$legacyProperty]; + if (\is_object($value)) { + $value = \get_object_vars($value); + } + if (!\is_array($value)) { + throw new \RuntimeException( + \sprintf('Invalid legacy %s map in %s: expected array or object, got %s', $legacyProperty, $file, \gettype($value)) + ); + } + } + + return $value; + } + + /** + * The typed, immutable view of this module's config/config.php. + * + * Prefer this over the legacy public properties for new code, e.g. + * `$configurator->config()->uploadFolders`. + * + * @since 1.2.0 + */ + public function config(): ModuleConfig + { + return $this->moduleConfig; + } +} diff --git a/src/Common/Confirm.php b/src/Common/Confirm.php new file mode 100644 index 0000000..d4da326 --- /dev/null +++ b/src/Common/Confirm.php @@ -0,0 +1,120 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + */ + +use Xmf\Module\Helper; +use Xmf\Request; +use Xoops\ModuleTools\Internal\Confirmation\ConfirmationResolver; + +/** + * Reusable "are you sure?" confirmation form for delete (and similar) actions. + * + * Centralises the per-module copy of `Common\Confirm` that ~18 modules carried. Those copies + * shared a bug: they derived the module directory with `basename(__DIR__)`, which — because the + * class lives in `/class/Common/` — resolved to the literal string `"Common"`, so the + * `_CO__DELETE_*` language constants were never found and the dialog silently fell back + * to hardcoded English (untranslated in non-English installs). + * + * This shared version takes the consumer's module directory EXPLICITLY (a class living in mtools + * cannot guess its consumer), so the translated constants resolve correctly. Pass it via + * {@see self::forModule()} or the `$moduleDirName` constructor argument. + * + * Usage: + * $confirm = Confirm::forModule( + * $helper, + * ['ok' => 1, 'id' => $id, 'op' => 'delete'], + * $_SERVER['REQUEST_URI'], + * \sprintf(\_CO_MYMODULE_FORM_SURE_DELETE, $obj->getTitle()) + * ); + * $GLOBALS['xoopsTpl']->assign('form', $confirm->getFormConfirm()->render()); + * + * Admin/UI tier (legacy): it builds a `\XoopsThemeForm`, so it requires a booted XOOPS and is + * NOT an XMF-graduation candidate. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +class Confirm +{ + /** + * @param array $hiddens hidden fields carried through the confirm POST + * @param string $action form action; defaults to the current REQUEST_URI + * @param string $object the message body shown next to the label + * @param string $title explicit form title (wins over the language constant) + * @param string $label explicit label (wins over the language constant) + * @param string|null $moduleDirName consumer module dir for `_CO__DELETE_*` lookups + */ + public function __construct( + private readonly array $hiddens, + private readonly string $action = '', + private readonly string $object = '', + private readonly string $title = '', + private readonly string $label = '', + private readonly ?string $moduleDirName = null + ) { + } + + /** + * Named constructor: derive the consumer module directory from its Helper. + * + * @param array $hiddens + */ + public static function forModule( + Helper $helper, + array $hiddens, + string $action = '', + string $object = '', + string $title = '', + string $label = '' + ): self { + return new self($hiddens, $action, $object, $title, $label, $helper->dirname()); + } + + /** + * Build the confirmation form. + */ + public function getFormConfirm(): \XoopsThemeForm + { + $model = new ConfirmationResolver()->resolve( + hiddens: $this->hiddens, + action: $this->action, + requestUri: Request::getString('REQUEST_URI', '', 'SERVER'), + object: $this->object, + title: $this->title, + label: $this->label, + moduleDirName: $this->moduleDirName, + ); + + \xoops_load('XoopsFormLoader'); + $form = new \XoopsThemeForm($model->title, 'formConfirm', $model->action, 'post', true); + $form->setExtra('enctype="multipart/form-data"'); + $form->addElement(new \XoopsFormLabel($model->label, $model->object)); + foreach ($model->hiddens as $key => $value) { + $form->addElement(new \XoopsFormHidden($key, $value)); + } + $form->addElement(new \XoopsFormHidden('ok', '1')); + $buttonTray = new \XoopsFormElementTray(''); + $buttonTray->addElement(new \XoopsFormButton('', 'confirm_submit', \_YES, 'submit')); + $buttonBack = new \XoopsFormButton('', 'confirm_back', \_NO, 'button'); + $buttonBack->setExtra('onclick="history.go(-1);return true;"'); + $buttonTray->addElement($buttonBack); + $form->addElement($buttonTray); + + return $form; + } +} diff --git a/src/Common/Db.php b/src/Common/Db.php new file mode 100644 index 0000000..b0b7e92 --- /dev/null +++ b/src/Common/Db.php @@ -0,0 +1,213 @@ + + * @author Mamba + */ + +/** + * Database helpers extracted from {@see SysUtility}. + * + * Every method takes the database handle EXPLICITLY (no `$GLOBALS['xoopsDB']` read), + * which removes the hidden global dependency and makes this an XMF candidate. + * {@see SysUtility} retains the legacy globals-reading signatures and forwards here. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +final class Db implements DbInterface +{ + /** + * Run a SELECT and return a validated result set, or throw on failure. + * + * @param \XoopsMySQLDatabase $db XOOPS Database + * @param string $sql a valid MySQL query + * @param int $limit number of records to return + * @param int $start offset of first record to return + * + * @return \mysqli_result query result + */ + public static function queryAndCheck(\XoopsMySQLDatabase $db, string $sql, int $limit = 0, int $start = 0): \mysqli_result + { + $result = $db->query($sql, $limit, $start); + + if (!$db->isResultSet($result) || !($result instanceof \mysqli_result)) { + throw new \RuntimeException( + \sprintf(\_DB_QUERY_ERROR, $sql) . $db->error(), + \E_USER_ERROR + ); + } + + return $result; + } + + /** + * QueryF and check if the result is a valid result set. + * + * @deprecated 2.7.0 queryF() bypassed Protector's SQL inspection. For SELECTs use + * {@see self::queryAndCheck()} (query()); for writes/DDL call $db->exec() + * directly. Retained for backward compatibility only. + * + * @param \XoopsMySQLDatabase $db XOOPS Database + * @param string $sql a valid MySQL query + * @param int $limit number of records to return + * @param int $start offset of first record to return + * + * @return \mysqli_result query result + */ + public static function queryFAndCheck(\XoopsMySQLDatabase $db, string $sql, int $limit = 0, int $start = 0): \mysqli_result + { + return self::queryAndCheck($db, $sql, $limit, $start); + } + + /** + * @param string $fieldname column name + * @param string $table table name (already prefixed) + */ + public static function fieldExists(\XoopsMySQLDatabase $db, string $fieldname, string $table): bool + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $fieldname) || !preg_match('/^[A-Za-z0-9_`]+$/', $table)) { + return false; + } + + $sql = 'SHOW COLUMNS FROM ' . $table . ' LIKE ' . $db->quote($fieldname); + $result = self::queryAndCheck($db, $sql); + + return ((($db->isResultSet($result) && ($result instanceof \mysqli_result)) ? $db->getRowsNum($result) : 0) > 0); + } + + /** + * Duplicate a single record and return the new auto-increment id (or false). + * + * @param string|array $tableName unprefixed table name + * @param string $idField auto-increment key field + * @param int $id id of the record to clone + * + * @return mixed new id on success, false otherwise + */ + public static function cloneRecord(\XoopsMySQLDatabase $db, $tableName, string $idField, int $id) + { + $new_id = false; + if ( + !preg_match('/^[A-Za-z0-9_]+$/', (string)$tableName) + || !preg_match('/^[A-Za-z0-9_]+$/', (string)$idField) + ) { + return false; + } + + $table = $db->prefix($tableName); + // copy content of the record you wish to clone + $sql = "SELECT * FROM $table WHERE `$idField`=" . $db->quote((string)$id); + $tempTable = null; + $result = $db->query($sql); + if ($db->isResultSet($result) && $result instanceof \mysqli_result) { + $tempTable = $db->fetchArray($result); + } + if (!$tempTable) { + \trigger_error($db->error()); + } + // set the auto-incremented id's value to blank. + unset($tempTable[$idField]); + // insert cloned copy of the original record + $columns = array_map(static fn ($column): string => '`' . str_replace('`', '``', (string)$column) . '`', array_keys($tempTable)); + $values = array_map(static fn ($value): string => $db->quote((string)$value), array_values($tempTable)); + $sql = "INSERT INTO $table (" . \implode(', ', $columns) . ') VALUES (' . \implode(', ', $values) . ')'; + $result = $db->exec($sql); + if (!$result) { + \trigger_error(\sprintf(\_DB_QUERY_ERROR, $sql) . $db->error(), \E_USER_ERROR); + } + // Return the new id + $new_id = $db->getInsertId(); + + return $new_id; + } + + /** + * Read the allowed values of an ENUM/SET column. + * + * @param string $tableName unprefixed table name + * @param string $columnName column to inspect + * + * @return list + */ + public static function enumerate(\XoopsMySQLDatabase $db, string $tableName, string $columnName): array + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $columnName)) { + return []; + } + + $table = $db->prefix($tableName); + + // TABLE_NAME / COLUMN_NAME are string columns in INFORMATION_SCHEMA: bind them as + // properly escaped string literals via quote() rather than interpolating raw values. + $sql = 'SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ' + . $db->quote($table) . ' AND COLUMN_NAME = ' . $db->quote($columnName); + $result = $db->query($sql); + if (!$db->isResultSet($result) || !($result instanceof \mysqli_result)) { + \trigger_error(\sprintf(\_DB_QUERY_ERROR, $sql) . $db->error(), \E_USER_ERROR); + } + + $row = (($db->isResultSet($result) && ($result instanceof \mysqli_result)) ? $db->fetchBoth($result) : false); + if (false === $row) { + return []; + } + $enumList = \explode(',', \str_replace("'", '', \mb_substr($row['COLUMN_TYPE'], 5, -6))); + + return $enumList; + } + + /** + * Check if a dB table exists. + * + * @deprecated Use Xmf\Database\Tables instead. + * + * @param string $tablename dB tablename with prefix + * @return bool true if table exists + */ + public static function tableExists(\XoopsMySQLDatabase $db, string $tablename): bool + { + $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 1); + \trigger_error(__FUNCTION__ . " is deprecated, called from {$trace[0]['file']} line {$trace[0]['line']}"); + $logger = self::runtimeLogger(); + if (is_object($logger) && method_exists($logger, 'addDeprecated')) { + $logger->addDeprecated( + \basename(\dirname(__DIR__, 2)) . ' Module: ' . __FUNCTION__ . ' function is deprecated, please use Xmf\Database\Tables method(s) instead.' . " Called from {$trace[0]['file']}line {$trace[0]['line']}" + ); + } + $sql = 'SHOW TABLES LIKE ' . $db->quote($tablename); + $result = self::queryAndCheck($db, $sql); + + return $db->isResultSet($result) && ($result instanceof \mysqli_result) && $db->getRowsNum($result) > 0; + } + + /** + * Build a parenthesised list of category ids for a SQL IN(...) fragment. + * + * Pure string construction (no database access). Every id is cast to int, so the + * returned fragment is always safe to interpolate into a WHERE ... IN(...) clause. + * + * @param array $cats list of category ids + */ + public static function blockAddCatSelect($cats): string + { + if (!\is_array($cats) || empty($cats)) { + return ''; + } + + return '(' . \implode(',', \array_map(\intval(...), $cats)) . ')'; + } + + /** @legacy-global-accessor */ + private static function runtimeLogger(): mixed + { + return $GLOBALS['xoopsLogger'] ?? null; + } +} diff --git a/src/Common/DbInterface.php b/src/Common/DbInterface.php new file mode 100644 index 0000000..763c2c7 --- /dev/null +++ b/src/Common/DbInterface.php @@ -0,0 +1,74 @@ + + */ + +/** + * Contract for the database helpers extracted from SysUtility. + * + * Unlike the legacy SysUtility methods (which read `$GLOBALS['xoopsDB']`), every + * method here takes the database handle as an EXPLICIT parameter, so the class has no + * hidden global dependency and is ready to graduate to XMF. {@see SysUtility} keeps + * the old globals-reading signatures and forwards to these. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +interface DbInterface +{ + /** + * Run a SELECT and return a validated result set, or throw on failure. + * + * @param \XoopsMySQLDatabase $db XOOPS Database + * @param string $sql a valid MySQL query + * @param int $limit number of records to return + * @param int $start offset of first record to return + * + * @return \mysqli_result query result + */ + public static function queryAndCheck(\XoopsMySQLDatabase $db, string $sql, int $limit = 0, int $start = 0): \mysqli_result; + + /** + * @param string $fieldname column name + * @param string $table table name (already prefixed) + */ + public static function fieldExists(\XoopsMySQLDatabase $db, string $fieldname, string $table): bool; + + /** + * Duplicate a single record and return the new auto-increment id (or false). + * + * @param array|string $tableName unprefixed table name + * @param string $idField auto-increment key field + * @param int $id id of the record to clone + * + * @return mixed new id on success, false otherwise + */ + public static function cloneRecord(\XoopsMySQLDatabase $db, $tableName, string $idField, int $id); + + /** + * Read the allowed values of an ENUM/SET column. + * + * @param string $tableName unprefixed table name + * @param string $columnName column to inspect + */ + public static function enumerate(\XoopsMySQLDatabase $db, string $tableName, string $columnName): array; + + /** + * Build a parenthesised list of category ids for a SQL IN(...) fragment. + * + * Pure string construction (no database access); lives here because the output + * is a SQL fragment consumed by block queries. + * + * @param array $cats list of category ids + */ + public static function blockAddCatSelect($cats): string; +} diff --git a/src/Common/DirectoryChecker.php b/src/Common/DirectoryChecker.php new file mode 100644 index 0000000..d5286b9 --- /dev/null +++ b/src/Common/DirectoryChecker.php @@ -0,0 +1,279 @@ +"; + $path_status .= "$displayPath (" . self::message('NOTAVAILABLE', 'not available') . ') '; + $path_status .= self::actionForm('mtools_createdir', (string)$path, $redirectFile, self::message('CREATETHEDIR', 'Create it'), $mode); + } elseif (@\is_writable($path)) { + $path_status = ""; + $path_status .= "$displayPath (" . self::message('AVAILABLE', 'available') . ') '; + $currentMode = mb_substr(\decoct(\fileperms($path)), 2); + if ($currentMode != \decoct($mode)) { + $path_status = ""; + $path_status .= $displayPath . ' ' . sprintf( + self::message('NOTWRITABLE', 'should be writable as %s; current mode is %s'), + \decoct($mode), + $currentMode + ); + $path_status .= ' ' . self::actionForm('mtools_setdirperm', (string)$path, $redirectFile, self::message('SETMPERM', 'Set the permission'), $mode); + } + } else { + $currentMode = mb_substr(\decoct(\fileperms($path)), 2); + $path_status = ""; + $path_status .= $displayPath . ' ' . sprintf( + self::message('NOTWRITABLE', 'should be writable as %s; current mode is %s'), + \decoct($mode), + $currentMode + ); + $path_status .= ' ' . self::actionForm('mtools_setdirperm', (string)$path, $redirectFile, self::message('SETMPERM', 'Set the permission'), $mode); + } + + return $path_status; + } + + /** + * Handle the "Create it" / "Set the permission" button posts. + * + * Call it from an admin page BEFORE any output; it redirects when it acts. + */ + public static function handleRequest(?string $redirectFile = null, ?string $allowedBasePath = null): void + { + $op = (string)($_POST['op'] ?? ''); + if ('mtools_createdir' !== $op && 'mtools_setdirperm' !== $op) { + return; + } + + $path = (string)($_POST['path'] ?? ''); + $mode = (int)\octdec((string)($_POST['mode'] ?? '0755')); + $redirectFile = self::safeRedirect($redirectFile); + + // Fail closed: no security service means no token check is possible, so no write. + $security = self::runtimeGlobal('xoopsSecurity'); + if (!\is_object($security) || !\method_exists($security, 'check') || !$security->check()) { + $errors = (\is_object($security) && \method_exists($security, 'getErrors')) + ? \implode('
', $security->getErrors()) + : 'Security token missing or invalid'; + \redirect_header($redirectFile, 3, '' !== $errors ? $errors : 'Security token missing or invalid'); + exit; + } + + if ('mtools_createdir' === $op) { + $ok = self::createDirectory($path, $mode, $allowedBasePath); + $msg = $ok ? self::message('DIRCREATED', 'The directory has been created') : self::message('DIRNOTCREATED', 'The directory cannot be created'); + } else { + $ok = self::setDirectoryPermissions($path, $mode, $allowedBasePath); + $msg = $ok ? self::message('PERMSET', 'The permission has been set') : self::message('PERMNOTSET', 'The permission cannot be set'); + } + + \redirect_header($redirectFile, 2, $msg . ': ' . self::escape($path)); + exit; + } + + /** + * @param $target + * @param int $mode + */ + public static function createDirectory($target, $mode = 0755, ?string $allowedBasePath = null): bool + { + if (!self::isAllowedPath((string)$target, $allowedBasePath)) { + return false; + } + + // https://www.php.net/manual/en/function.mkdir.php + return \is_dir($target) + || (self::createDirectory(\dirname($target), $mode, $allowedBasePath) + && (Filesystem::mkdir($target, self::normalizeMode($mode, 0755), false) || \is_dir($target))); + } + + /** + * @param $target + * @param int $mode + */ + public static function setDirectoryPermissions($target, $mode = 0755, ?string $allowedBasePath = null): bool + { + if (!self::isAllowedPath((string)$target, $allowedBasePath)) { + return false; + } + + return @\chmod($target, self::normalizeMode($mode, 0755)); + } + + /** + * @param $dir_path + */ + public static function dirExists($dir_path): bool + { + return \is_dir($dir_path); + } + + private static function actionForm(string $op, string $path, string $redirectFile, string $label, int|string $mode): string + { + $security = self::runtimeGlobal('xoopsSecurity'); + $token = (\is_object($security) && \method_exists($security, 'getTokenHTML')) ? $security->getTokenHTML() : ''; + + return "
" + . $token + . "" + . "" + . "" + . "' + . '
'; + } + + private static function safeRedirect(?string $redirectFile): string + { + $redirectFile = (string)($redirectFile ?? ''); + if ('' === $redirectFile || \str_contains($redirectFile, '://') || \str_starts_with($redirectFile, '//')) { + $redirectFile = (string)($_SERVER['SCRIPT_NAME'] ?? 'index.php'); + } + + return $redirectFile; + } + + private static function isAllowedPath(string $path, ?string $allowedBasePath): bool + { + if ('' === $path || str_contains($path, "\0") || str_contains($path, '://')) { + return false; + } + + if (null === $allowedBasePath) { + return self::isUnderKnownBase($path); + } + + $base = realpath($allowedBasePath); + $target = self::resolveExistingPath($path); + + return false !== $base + && false !== $target + && self::isContainedPath($target, $base); + } + + private static function isUnderKnownBase(string $path): bool + { + if (str_contains($path, '..')) { + return false; + } + + $target = self::resolveExistingPath($path); + if (false === $target) { + return false; + } + + foreach (['XOOPS_ROOT_PATH', 'XOOPS_UPLOAD_PATH'] as $constant) { + if (!defined($constant)) { + continue; + } + + $base = realpath((string)constant($constant)); + if (false !== $base && self::isContainedPath($target, $base)) { + return true; + } + } + + return false; + } + + private static function isContainedPath(string $target, string $base): bool + { + $base = rtrim($base, DIRECTORY_SEPARATOR); + + return $target === $base || str_starts_with($target, $base . DIRECTORY_SEPARATOR); + } + + private static function resolveExistingPath(string $path): string|false + { + $current = $path; + while ('' !== $current && $current !== \dirname($current)) { + $resolved = realpath($current); + if (false !== $resolved) { + return $resolved; + } + $current = \dirname($current); + } + + return realpath($current); + } + + private static function normalizeMode($mode, int $fallback): int + { + $mode = (int)$mode; + $allowedModes = [0644, 0664, 0755, 0775]; + + return in_array($mode, $allowedModes, true) ? $mode : $fallback; + } + + private static function message(string $suffix, string $fallback): string + { + $constant = '_CO_MTOOLS_' . $suffix; + if (!defined($constant)) { + PackageLanguage::load('common', self::siteLanguage()); + } + + return defined($constant) ? (string)constant($constant) : $fallback; + } + + /** @legacy-global-accessor */ + private static function runtimeGlobal(string $name): mixed + { + return $GLOBALS[$name] ?? null; + } + + /** @legacy-global-accessor */ + private static function siteLanguage(): string + { + $config = $GLOBALS['xoopsConfig'] ?? []; + + return \is_array($config) ? (string)($config['language'] ?? 'english') : 'english'; + } + + private static function escape(string $value): string + { + return HtmlBuilder::escape($value); + } +} diff --git a/src/Common/FileChecker.php b/src/Common/FileChecker.php new file mode 100644 index 0000000..e77b4c3 --- /dev/null +++ b/src/Common/FileChecker.php @@ -0,0 +1,221 @@ +"; + $path_status .= "$filePath (" . self::message('AVAILABLE', 'available') . ') '; + } else { + $path_status = ""; + $path_status .= "$filePath (" . self::message('NOTAVAILABLE', 'not available') . ') '; + } + } else { + if (self::compareFiles($file_path, $original_file_path)) { + $path_status = ""; + $path_status .= "$filePath (" . self::message('AVAILABLE', 'available') . ') '; + } else { + $path_status = ""; + $path_status .= "$filePath (" . self::message('NOTAVAILABLE', 'not available') . ') '; + } + } + + return $path_status; + } + + /** + * @param $source_path + * @param $destination_path + */ + public static function copyFile($source_path, $destination_path, ?string $allowedBasePath = null): bool + { + if ( + !self::isAllowedPath((string)$source_path, $allowedBasePath) + || !self::isAllowedPath((string)$destination_path, $allowedBasePath) + ) { + return false; + } + + return Filesystem::copy((string)$source_path, (string)$destination_path); + } + + /** + * @param $file1_path + * @param $file2_path + */ + public static function compareFiles($file1_path, $file2_path): bool + { + if (!self::fileExists($file1_path) || !self::fileExists($file2_path)) { + return false; + } + if (\filetype($file1_path) !== \filetype($file2_path)) { + return false; + } + if (\filesize($file1_path) !== \filesize($file2_path)) { + return false; + } + + $crc1 = \hash_file('crc32b', $file1_path); + $crc2 = \hash_file('crc32b', $file2_path); + + return false !== $crc1 && false !== $crc2 && $crc1 === $crc2; + } + + /** + * @param $file_path + */ + public static function fileExists($file_path): bool + { + return \is_file($file_path); + } + + /** + * @param $target + * @param int $mode + */ + public static function setFilePermissions($target, $mode = 0644, ?string $allowedBasePath = null): bool + { + if (!self::isAllowedPath((string)$target, $allowedBasePath)) { + return false; + } + + return @\chmod($target, self::normalizeMode($mode, 0644)); + } + + private static function isAllowedPath(string $path, ?string $allowedBasePath): bool + { + if ('' === $path || str_contains($path, "\0") || str_contains($path, '://')) { + return false; + } + + if (null === $allowedBasePath) { + return self::isUnderKnownBase($path); + } + + $base = realpath($allowedBasePath); + $target = self::resolveExistingPath($path); + + return false !== $base + && false !== $target + && self::isContainedPath($target, $base); + } + + private static function isUnderKnownBase(string $path): bool + { + if (str_contains($path, '..')) { + return false; + } + + $target = self::resolveExistingPath($path); + if (false === $target) { + return false; + } + + foreach (['XOOPS_ROOT_PATH', 'XOOPS_UPLOAD_PATH'] as $constant) { + if (!defined($constant)) { + continue; + } + + $base = realpath((string)constant($constant)); + if (false !== $base && self::isContainedPath($target, $base)) { + return true; + } + } + + return false; + } + + private static function isContainedPath(string $target, string $base): bool + { + $base = rtrim($base, DIRECTORY_SEPARATOR); + + return $target === $base || str_starts_with($target, $base . DIRECTORY_SEPARATOR); + } + + private static function resolveExistingPath(string $path): string|false + { + $current = $path; + while ('' !== $current && $current !== \dirname($current)) { + $resolved = realpath($current); + if (false !== $resolved) { + return $resolved; + } + $current = \dirname($current); + } + + return realpath($current); + } + + private static function normalizeMode($mode, int $fallback): int + { + $mode = (int)$mode; + $allowedModes = [0644, 0664, 0755, 0775]; + + return in_array($mode, $allowedModes, true) ? $mode : $fallback; + } + + private static function message(string $suffix, string $fallback): string + { + $constant = '_CO_MTOOLS_' . $suffix; + if (!defined($constant)) { + PackageLanguage::load('common', self::siteLanguage()); + } + + return defined($constant) ? (string)constant($constant) : $fallback; + } + + /** @legacy-global-accessor */ + private static function siteLanguage(): string + { + $config = $GLOBALS['xoopsConfig'] ?? []; + + return \is_array($config) ? (string)($config['language'] ?? 'english') : 'english'; + } + + private static function escape(string $value): string + { + return HtmlBuilder::escape($value); + } +} diff --git a/src/Common/FilesManagement.php b/src/Common/FilesManagement.php new file mode 100644 index 0000000..978cf7d --- /dev/null +++ b/src/Common/FilesManagement.php @@ -0,0 +1,270 @@ +history.go(-1);'); + } + } + + /** + * @param $file + * @param $folder + * @return bool + */ + public static function copyFile(string $file, string $folder): bool + { + if (!self::isSafeFilesystemPath($file) || !self::isSafeFilesystemPath($folder)) { + return false; + } + + return Filesystem::copy($file, $folder); + } + + /** + * @param $src + * @param $dst + */ + public static function recurseCopy($src, $dst): void + { + if (!self::isSafeFilesystemPath((string)$src) || !self::isSafeFilesystemPath((string)$dst)) { + throw new \RuntimeException('Refusing unsafe copy path.'); + } + + if (!\is_dir($src)) { + throw new \RuntimeException('The directory ' . $src . ' could not be opened.'); + } + + if (!Filesystem::copyDirectory((string)$src, (string)$dst)) { + throw new \RuntimeException('The directory ' . $dst . ' could not be created.'); + } + } + + /** + * Copy a file, or recursively copy a folder and its contents + * @param string $source Source path + * @param string $dest Destination path + * @return bool Returns true on success, false on failure + * @author Aidan Lister + * @version 1.0.1 + * @link https://aidanlister.com/2004/04/recursively-copying-directories-in-php/ + */ + public static function xcopy($source, $dest): bool + { + if (!self::isSafeFilesystemPath((string)$source) || !self::isSafeFilesystemPath((string)$dest)) { + return false; + } + + // Check for symlinks + if (\is_link($source)) { + $target = \readlink($source); + return false !== $target && \symlink($target, $dest); + } + + // Simple copy for a file + if (\is_file($source)) { + return Filesystem::copy($source, $dest); + } + + if (@\is_dir($source)) { + // Deep copy the directory and its contents + if (!Filesystem::copyDirectory((string)$source, (string)$dest)) { + throw new \RuntimeException(\sprintf('Directory "%s" was not created', $dest)); + } + } + + return true; + } + + /** + * Remove files and (sub)directories + * + * @param string $src source directory to delete + * + * @return bool true on success + * @uses \Xmf\Module\Helper::isUserAdmin() + * + * @uses \Xmf\Module\Helper::getHelper() + */ + public static function deleteDirectory($src): bool + { + if (!self::isSafeFilesystemPath((string)$src)) { + return false; + } + + // Only continue if user is a 'global' Admin + $user = self::runtimeUser(); + if (!is_object($user) || !($user instanceof \XoopsUser) || !$user->isAdmin()) { + return false; + } + + return Filesystem::deleteDirectory((string)$src); + } + + /** + * Recursively remove directory + * + * @todo currently won't remove directories with hidden files, should it? + * + * @param string $src directory to remove (delete) + * + * @return bool true on success + */ + public static function rrmdir($src): bool + { + if (!self::isSafeFilesystemPath((string)$src)) { + return false; + } + + // Only continue if user is a 'global' Admin + $user = self::runtimeUser(); + if (!is_object($user) || !($user instanceof \XoopsUser) || !$user->isAdmin()) { + return false; + } + + // If source is not a directory stop processing + if (!\is_dir($src)) { + return false; + } + + return Filesystem::deleteDirectory((string)$src); // remove the directory & return results + } + + /** + * Recursively move files from one directory to another + * + * @param string $src - Source of files being moved + * @param string $dest - Destination of files being moved + * + * @return bool true on success + */ + public static function rmove($src, $dest): bool + { + if (!self::isSafeFilesystemPath((string)$src) || !self::isSafeFilesystemPath((string)$dest)) { + return false; + } + + // Only continue if user is a 'global' Admin + $user = self::runtimeUser(); + if (!is_object($user) || !($user instanceof \XoopsUser) || !$user->isAdmin()) { + return false; + } + + // If source is not a directory stop processing + if (!\is_dir($src)) { + return false; + } + + // If the destination directory does not exist and could not be created stop processing + if (!\is_dir($dest) && !Filesystem::mkdir($dest, 0755) && !\is_dir($dest)) { + return false; + } + + return Filesystem::moveDirectory((string)$src, (string)$dest); // move contents & remove the source directory + } + + /** + * Recursively copy directories and files from one directory to another + * + * @param string $src - Source of files being moved + * @param string $dest - Destination of files being moved + * + * @return bool true on success + * @uses \Xmf\Module\Helper::isUserAdmin() + * + * @uses \Xmf\Module\Helper::getHelper() + */ + public static function rcopy($src, $dest): bool + { + if (!self::isSafeFilesystemPath((string)$src) || !self::isSafeFilesystemPath((string)$dest)) { + return false; + } + + // Only continue if user is a 'global' Admin + $user = self::runtimeUser(); + if (!is_object($user) || !($user instanceof \XoopsUser) || !$user->isAdmin()) { + return false; + } + + // If source is not a directory stop processing + if (!\is_dir($src)) { + return false; + } + + // If the destination directory does not exist and could not be created stop processing + if (!\is_dir($dest) && !Filesystem::mkdir($dest, 0755) && !\is_dir($dest)) { + return false; + } + + return Filesystem::copyDirectory((string)$src, (string)$dest); + } + + private static function isSafeFilesystemPath(string $path): bool + { + return '' !== $path + && !str_contains($path, "\0") + && !str_contains($path, '://') + && !str_contains($path, '..'); + } + + /** @legacy-global-accessor */ + private static function runtimeUser(): mixed + { + return $GLOBALS['xoopsUser'] ?? null; + } +} diff --git a/src/Common/Highlighter.php b/src/Common/Highlighter.php new file mode 100644 index 0000000..9ed24fa --- /dev/null +++ b/src/Common/Highlighter.php @@ -0,0 +1,28 @@ + */ + private array $terms; + + public function __construct(string $keywords, bool $singleWords = true, private string $cssClass = 'moduletools-highlight') + { + $terms = $singleWords ? preg_split('/\s+/u', trim($keywords)) : [trim($keywords)]; + $this->terms = array_values(array_filter(array_unique(array_map(strval(...), $terms ?: [])), static fn (string $term): bool => '' !== $term)); + } + + public function highlight(string $text): string + { + $escaped = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); + if ([] === $this->terms) { + return $escaped; + } + $pattern = '/(' . implode('|', array_map(static fn (string $term): string => preg_quote($term, '/'), $this->terms)) . ')/iu'; + return preg_replace($pattern, '$1', $escaped) ?? $escaped; + } +} diff --git a/src/Common/ImageResizerInterface.php b/src/Common/ImageResizerInterface.php new file mode 100644 index 0000000..f0b4dbe --- /dev/null +++ b/src/Common/ImageResizerInterface.php @@ -0,0 +1,21 @@ +criteria = $criteria ?? new \CriteriaCompo(); + $this->field_name = $field_name ?? $this->objHandler->identifierName; + // $this->alphabet = (count($alphabet) > 0) ? $alphabet : range('a', 'z'); // is there a way to get locale alphabet? + // $this->alphabet = getLocalAlphabet(); + $config = self::runtimeGlobal('xoopsConfig'); + $language = is_array($config) ? (string) ($config['language'] ?? 'english') : 'english'; + PackageLanguage::load('common', $language); + $languageFile = $this->modHelper->path('language/' . $language . '/alphabet.php'); + if (!is_file($languageFile)) { + $languageFile = $this->modHelper->path('language/english/alphabet.php'); + } + $this->alphabet = is_file($languageFile) ? require $languageFile : range('a', 'z'); + $this->url = $url ?? $_SERVER['SCRIPT_NAME']; + if ('' !== $extra_arg && ('&' !== \mb_substr($extra_arg, -5) || '&' !== \mb_substr($extra_arg, -1))) { + $this->extra = '&' . $extra_arg; + } + } + + /** + * Create choice by letter + * + * @param null|int $alphaCount + * @param null|int $howmanyother + */ + public function render($alphaCount = null, $howmanyother = null): string + { + $moduleDirName = $this->modHelper->dirname(); + $moduleDirNameUpper = \mb_strtoupper($moduleDirName); + \xoops_loadLanguage('common', $moduleDirName); + \xoops_loadLanguage('alphabet', $moduleDirName); + $all = \constant('_CO_MTOOLS_ALL'); + $other = \constant('_CO_MTOOLS_OTHER'); + + $ret = ''; + + if (!$this->caseSensitive) { + $this->criteria->setGroupBy('UPPER(LEFT(' . $this->field_name . ',1))'); + } else { + $this->criteria->setGroupBy('LEFT(' . $this->field_name . ',1)'); + } + $countsByLetters = $this->objHandler->getCounts($this->criteria); + // fill alphabet array + $alphabetArray = []; + $letter_array = []; + + $letter = 'All'; + $letter_array['letter'] = $all; + $letter_array['count'] = $alphaCount; + $letter_array['url'] = $this->url; + $alphabetArray[$letter] = $letter_array; + + foreach ($this->alphabet as $letter) { + $letter_array = []; + $letter_array['letter'] = $letter; + if (!$this->caseSensitive) { + if (isset($countsByLetters[\mb_strtoupper($letter)])) { + $letter_array['count'] = $countsByLetters[\mb_strtoupper($letter)]; + $letter_array['url'] = $this->url . '?' . $this->arg_name . '=' . $letter . $this->extra; + } else { + $letter_array['count'] = 0; + $letter_array['url'] = ''; + } + } else { + if (isset($countsByLetters[$letter])) { + $letter_array['count'] = $countsByLetters[$letter]; + $letter_array['url'] = $this->url . '?' . $this->arg_name . '=' . $letter . $this->extra; + } else { + $letter_array['count'] = 0; + $letter_array['url'] = ''; + } + } + $alphabetArray[$letter] = $letter_array; + unset($letter_array); + } + + $letter_array['letter'] = $other; + $letter_array['count'] = $howmanyother; + $letter_array['url'] = $this->url . '?init=Other'; + $alphabetArray[$letter] = $letter_array; + + // render output + $theme = self::runtimeGlobal('xoTheme'); + $xoops = self::runtimeGlobal('xoops'); + if (!is_object($theme)) { + require_once $xoops->path('/class/theme.php'); + self::setRuntimeGlobal('xoTheme', new \xos_opal_Theme()); + } + require_once $xoops->path('/class/template.php'); + $choiceByLetterTpl = new \XoopsTpl(); + $choiceByLetterTpl->setCaching(\Smarty\Smarty::CACHING_OFF); + $choiceByLetterTpl->assign('alphabet', $alphabetArray); + $ret .= $choiceByLetterTpl->fetch("db:{$this->modHelper->dirname()}_letterschoice.tpl"); + unset($choiceByLetterTpl); + + return $ret; + } + + /** @legacy-global-accessor */ + private static function runtimeGlobal(string $name): mixed + { + return $GLOBALS[$name] ?? null; + } + + /** @legacy-global-accessor */ + private static function setRuntimeGlobal(string $name, mixed $value): void + { + $GLOBALS[$name] = $value; + } +} diff --git a/src/Common/Media/MediaUploadRequest.php b/src/Common/Media/MediaUploadRequest.php new file mode 100644 index 0000000..123a593 --- /dev/null +++ b/src/Common/Media/MediaUploadRequest.php @@ -0,0 +1,38 @@ + $file one $_FILES[...] entry + * @param string $targetDir absolute dir for the original + * @param string $thumbnailDir absolute dir for thumbnails ('' = none) + * @param int[] $thumbnailWidths widths to generate + * @param string[] $allowedMime accepted MIME types + */ + public function __construct( + public array $file, + public string $targetDir, + public string $thumbnailDir = '', + public array $thumbnailWidths = [150, 400], + public array $allowedMime = ['image/jpeg', 'image/pjpeg', 'image/png', 'image/gif', 'image/webp'], + public int $maxBytes = 5242880, + public int $maxWidth = 4000, + public int $maxHeight = 4000, + public string $namePrefix = 'img', + public int $quality = 85, + ) { + } +} diff --git a/src/Common/Migrate.php b/src/Common/Migrate.php new file mode 100644 index 0000000..6fbc1ce --- /dev/null +++ b/src/Common/Migrate.php @@ -0,0 +1,108 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @deprecated since 1.4.0. Use Xmf\Database\Migrate for new code; retained through XOOPS 2.8. + */ +class Migrate extends \Xmf\Database\Migrate +{ + private $renameTables; + + /** + * Migrate constructor. + * @param Configurator|null $configurator + */ + public function __construct(?Configurator $configurator = null) + { + if (null === $configurator) { + throw new \InvalidArgumentException( + 'Migrate requires the consumer module Configurator; pass new Configurator($modulePath).' + ); + } + + $this->renameTables = $configurator->renameTables; + parent::__construct(basename($configurator->baseDir())); + } + + /** + * change table prefix if needed + */ + private function changePrefix(): void + { + foreach ($this->renameTables as $oldName => $newName) { + if ($this->tableHandler->useTable($oldName) && !$this->tableHandler->useTable($newName)) { + $this->tableHandler->renameTable($oldName, $newName); + } + } + } + + /** + * Change integer IPv4 column to varchar IPv6 capable + * + * @param string $tableName table to convert + * @param string $columnName column with IP address + */ + private function convertIPAddresses($tableName, $columnName): void + { + if ($this->tableHandler->useTable($tableName)) { + $attributes = $this->tableHandler->getColumnAttributes($tableName, $columnName); + if (str_contains($attributes, ' int(')) { + if (!str_contains($attributes, 'unsigned')) { + $this->tableHandler->alterColumn($tableName, $columnName, " bigint(16) NOT NULL DEFAULT '0' "); + $this->tableHandler->update($tableName, [$columnName => "4294967296 + $columnName"], "WHERE $columnName < 0", false); + } + $this->tableHandler->alterColumn($tableName, $columnName, " varchar(45) NOT NULL DEFAULT '' "); + $this->tableHandler->update($tableName, [$columnName => "INET_NTOA($columnName)"], '', false); + } + } + } + + /** + * Move columns to another table + */ + private function moveDoColumns(): void + { + //for an example, see newbb 5.0 + } + + /** + * Perform any upfront actions before synchronizing the schema + * + * Some typical uses include + * table and column renames + * data conversions + */ + protected function preSyncActions(): void + { + // change table prefix + if ($this->renameTables && \is_array($this->renameTables)) { + $this->changePrefix(); + } + // // columns dohtml, dosmiley, doxcode, doimage and dobr moved between tables as some point + // $this->moveDoColumns(); + // // Convert IP address columns from int to readable varchar(45) for IPv6 + // $this->convertIPAddresses('extgallery_posts', 'poster_ip'); + // $this->convertIPAddresses('extgallery_report', 'reporter_ip'); + } +} diff --git a/src/Common/ModuleConfig.php b/src/Common/ModuleConfig.php new file mode 100644 index 0000000..1fd37d6 --- /dev/null +++ b/src/Common/ModuleConfig.php @@ -0,0 +1,70 @@ +config()->uploadFolders` is always a + * typed array. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +final readonly class ModuleConfig +{ + /** + * @param array $uploadFolders + * @param array $copyBlankFiles + * @param array $copyTestFolders + * @param array $templateFolders + * @param array $oldFiles + * @param array $oldFolders + * @param array $renameTables + * @param array $renameColumns + * @param array $moduleStats + * @param array $paths + */ + public function __construct( + public string $name = '', + public array $uploadFolders = [], + public array $copyBlankFiles = [], + public array $copyTestFolders = [], + public array $templateFolders = [], + public array $oldFiles = [], + public array $oldFolders = [], + public array $renameTables = [], + public array $renameColumns = [], + public array $moduleStats = [], + public string $modCopyright = '', + public array $paths = [], + ) { + } + + /** + * Build from the raw object returned by `config/config.php`, coercing types and + * defaulting any field the module omitted. + */ + public static function fromObject(object $config): self + { + return new self( + name: (string) ($config->name ?? ''), + uploadFolders: (array) ($config->uploadFolders ?? []), + copyBlankFiles: (array) ($config->copyBlankFiles ?? []), + copyTestFolders: (array) ($config->copyTestFolders ?? []), + templateFolders: (array) ($config->templateFolders ?? []), + oldFiles: (array) ($config->oldFiles ?? []), + oldFolders: (array) ($config->oldFolders ?? []), + renameTables: (array) ($config->renameTables ?? []), + renameColumns: (array) ($config->renameColumns ?? []), + moduleStats: (array) ($config->moduleStats ?? []), + paths: (array) ($config->paths ?? []), + modCopyright: (string) ($config->modCopyright ?? ''), + ); + } +} diff --git a/src/Common/ModuleFeedback.php b/src/Common/ModuleFeedback.php new file mode 100644 index 0000000..ff4d6a3 --- /dev/null +++ b/src/Common/ModuleFeedback.php @@ -0,0 +1,152 @@ + + * @author Wedega - Email: + * @author Fernando Santos (topet05) + */ + +use Xoops\Helpers\Utility\HtmlBuilder; + +/** + * Class Object ModuleFeedback + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + */ +class ModuleFeedback extends \XoopsObject +{ + public $name = ''; + public $email = ''; + public $site = ''; + public $type = ''; + public $content = ''; + private readonly ?string $moduleDirname; + + /** + * Constructor + */ + public function __construct(private readonly ?\XoopsModule $module = null, ?string $moduleDirname = null) + { + $this->moduleDirname = null !== $moduleDirname ? basename($moduleDirname) : null; + } + + /** + * @static function &getInstance + * + */ + public static function getInstance(): self + { + static $instance = null; + $instance ??= new self(); + return $instance; + } + + /** + * @public function getFormFeedback: + * provide form for sending a feedback to module author + * @param bool $action + */ + public function getFormFeedback($action = false, ?\XoopsModule $module = null): \XoopsThemeForm + { + if (false === $action) { + $action = str_replace(["\r", "\n"], '', (string)($_SERVER['REQUEST_URI'] ?? 'feedback.php')); + } + $module ??= $this->module ?? $this->resolveModule(); + if (!$module instanceof \XoopsModule) { + throw new \RuntimeException('ModuleFeedback requires a consumer module context.'); + } + + $moduleDirName = (string)$module->getVar('dirname'); + $moduleDirNameUpper = \mb_strtoupper($moduleDirName); + // Get Theme Form + \xoops_load('XoopsFormLoader'); + $form = new \XoopsThemeForm($this->constantValue($moduleDirNameUpper, 'FB_FORM_TITLE', 'Feedback'), 'formfeedback', (string)$action, 'post', true); + $form->setExtra('enctype="multipart/form-data"'); + + $recipient = new \XoopsFormText($this->constantValue($moduleDirNameUpper, 'FB_RECIPIENT', 'Recipient'), 'recipient', 50, 255, (string)$module->getInfo('author_mail')); + $recipient->setExtra('disabled="disabled"'); + $form->addElement($recipient); + $your_name = new \XoopsFormText($this->constantValue($moduleDirNameUpper, 'FB_NAME', 'Name'), 'your_name', 50, 255, $this->name); + $your_name->setExtra('placeholder="' . HtmlBuilder::escape($this->constantValue($moduleDirNameUpper, 'FB_NAME_PLACEHOLER', 'Your name')) . '"'); + $form->addElement($your_name); + $your_site = new \XoopsFormText($this->constantValue($moduleDirNameUpper, 'FB_SITE', 'Website'), 'your_site', 50, 255, $this->site); + $your_site->setExtra('placeholder="' . HtmlBuilder::escape($this->constantValue($moduleDirNameUpper, 'FB_SITE_PLACEHOLER', 'Your website')) . '"'); + $form->addElement($your_site); + $your_mail = new \XoopsFormText($this->constantValue($moduleDirNameUpper, 'FB_MAIL', 'Email'), 'your_mail', 50, 255, $this->email); + $your_mail->setExtra('placeholder="' . HtmlBuilder::escape($this->constantValue($moduleDirNameUpper, 'FB_MAIL_PLACEHOLER', 'Your email')) . '"'); + $form->addElement($your_mail); + + $fbtypeSelect = new \XoopsFormSelect($this->constantValue($moduleDirNameUpper, 'FB_TYPE', 'Type'), 'fb_type', $this->type); + $fbtypeSelect->addOption('', ''); + foreach (['SUGGESTION' => 'Suggestion', 'BUGS' => 'Bug report', 'TESTIMONIAL' => 'Testimonial', 'FEATURES' => 'Feature request', 'OTHERS' => 'Other'] as $suffix => $fallback) { + $label = $this->constantValue($moduleDirNameUpper, 'FB_TYPE_' . $suffix, $fallback); + $fbtypeSelect->addOption($label, $label); + } + $form->addElement($fbtypeSelect, true); + + $editorConfigs = []; + $editorConfigs['name'] = 'fb_content'; + $editorConfigs['value'] = $this->content; + $editorConfigs['rows'] = 5; + $editorConfigs['cols'] = 40; + $editorConfigs['width'] = '100%'; + $editorConfigs['height'] = '400px'; + /** @var \XoopsModuleHandler $moduleHandler */ + $moduleHandler = xoops_getHandler('module'); + $systemModule = $moduleHandler->getByDirname('system'); + /** @var \XoopsConfigHandler $configHandler */ + $configHandler = xoops_getHandler('config'); + $config = $configHandler->getConfigsByCat(0, $systemModule->getVar('mid')); + $editorConfigs['editor'] = $config['general_editor']; + $editor = new \XoopsFormEditor($this->constantValue($moduleDirNameUpper, 'FB_TYPE_CONTENT', 'Message'), 'fb_content', $editorConfigs); + $form->addElement($editor, true); + + $form->addElement(new \XoopsFormHidden('op', 'send')); + $form->addElement(new \XoopsFormButtonTray('', \_SUBMIT, 'submit', '', false)); + + return $form; + } + + /** @legacy-global-accessor */ + private function resolveModule(): ?\XoopsModule + { + if (isset($GLOBALS['xoopsModule']) && $GLOBALS['xoopsModule'] instanceof \XoopsModule) { + return $GLOBALS['xoopsModule']; + } + + if (null === $this->moduleDirname || !\class_exists(\XoopsModule::class)) { + return null; + } + + $module = \XoopsModule::getByDirname($this->moduleDirname); + + return $module instanceof \XoopsModule ? $module : null; + } + + /** Consumer module constant first, then the package catalog, then the English fallback. */ + private function constantValue(string $moduleDirNameUpper, string $suffix, string $fallback): string + { + $consumerConstant = '_CO_' . $moduleDirNameUpper . '_' . $suffix; + if ('' !== $moduleDirNameUpper && \defined($consumerConstant)) { + return (string)\constant($consumerConstant); + } + \Xoops\ModuleTools\Internal\PackageLanguage::load('common'); + $constant = '_CO_MTOOLS_' . $suffix; + + return \defined($constant) ? (string)\constant($constant) : $fallback; + } +} diff --git a/src/Common/ModuleStats.php b/src/Common/ModuleStats.php new file mode 100644 index 0000000..f67f0a1 --- /dev/null +++ b/src/Common/ModuleStats.php @@ -0,0 +1,37 @@ + + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + */ +trait ModuleStats +{ + /** + * @param \Xoops\ModuleTools\Common\Configurator $configurator + * @param array $moduleStats + */ + public static function getModuleStats($configurator, $moduleStats): array + { + if (\count($configurator->moduleStats) > 0) { + foreach (\array_keys($configurator->moduleStats) as $i) { + $moduleStats[$i] = $configurator->moduleStats[$i]; + } + } + + return $moduleStats; + } +} diff --git a/src/Common/ObjectTree.php b/src/Common/ObjectTree.php new file mode 100644 index 0000000..8b031cb --- /dev/null +++ b/src/Common/ObjectTree.php @@ -0,0 +1,90 @@ + 0) { + $value = $this->tree[$key]['obj']->getVar($this->myId); + $optionsArray[$value] = $prefix_curr . $this->tree[$key]['obj']->getVar($fieldName); + $prefix_curr .= $prefix_orig; + } + if (isset($this->tree[$key]['child']) && !empty($this->tree[$key]['child'])) { + foreach ($this->tree[$key]['child'] as $childkey) { + $this->makeSelBoxOptionsArray($fieldName, $childkey, $optionsArray, $prefix_orig, $prefix_curr); + } + } + + return $optionsArray; + } + + /** + * Make a select box with options from the tree + * + * @param string $name + * @param string $fieldName Name of the member variable from the node objects that should be used as the title for the options. + * @param string $prefix String to indent deeper levels + * @param string $selected + * @param bool $addEmptyOption Set TRUE to add an empty option with value "0" at the top of the hierarchy + * @param int $key ID of the object to display as the root of select options + * + * @param string $extra + * @return string|array Associative array of value->name pairs, useful for XoopsFormSelect->addOptionArray method + * addOptionArray method + */ + public function makeSelBox($name, $fieldName, $prefix = '-', $selected = '', $addEmptyOption = false, $key = 0, $extra = '') + { + $optionsArray = []; + if ($addEmptyOption) { + $optionsArray[0] = ''; + } + + return $this->makeSelBoxOptionsArray($fieldName, $key, $optionsArray, $prefix); + } + } +} diff --git a/src/Common/Output.php b/src/Common/Output.php new file mode 100644 index 0000000..8406f2c --- /dev/null +++ b/src/Common/Output.php @@ -0,0 +1,126 @@ + + * @author Mamba + */ + +use Xmf\Request; +use Xoops\ModuleTools\Internal\Presentation\SortControlBuilder; + +/** + * Admin/UI output helpers extracted from {@see SysUtility}. + * + * These produce HTML or write to theme/template globals, so they stay in mtools as a + * legacy-UI facade and are NOT promoted to XMF. They are deliberately context-EXPLICIT: + * the consumer's module helper is passed in, rather than resolved via late static + * binding, so the methods behave identically no matter which class forwards to them. + * {@see SysUtility::selectSorting()} / {@see SysUtility::getEditor()} resolve the + * consumer Helper and forward here, preserving the old globals-friendly call style. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +class Output +{ + /** + * Render the ascending/descending sort switch for a list column. + * + * @param string $text label shown before the sort arrows + * @param string $form_sort the column this control sorts by + * @param \Xmf\Module\Helper $helper the CONSUMER's module helper (icons/url) + */ + public static function selectSorting($text, $form_sort, $helper): string + { + global $start, $order, $file_cat, $sort, $xoopsModule; + + $pathModIcon16 = $helper->url($helper->getModule()->getInfo('modicons16')); + + $model = new SortControlBuilder()->build( + requestUri: Request::getString('REQUEST_URI', '', 'SERVER'), + scriptName: Request::getString('SCRIPT_NAME', '', 'SERVER'), + start: $start, + currentOrder: (string) $order, + currentSort: (string) $sort, + requestedSort: (string) $form_sort, + ); + $select_view = '
' . $text . ''; + $select_view .= ' ASC'; + $select_view .= 'DESC'; + $select_view .= '
'; + + return $select_view; + } + + /** + * Build a description editor element for the CONSUMER's module. + * + * @param \Xmf\Module\Helper $helper the CONSUMER's module helper (editor config) + * @param array|null $options editor options; sensible defaults when null + * @return \XoopsFormDhtmlTextArea|\XoopsFormEditor + */ + public static function getEditor($helper, $options = null) + { + if (null === $options) { + $options = []; + $options['name'] = 'Editor'; + $options['value'] = 'Editor'; + $options['rows'] = 10; + $options['cols'] = '100%'; + $options['width'] = '100%'; + $options['height'] = '400px'; + } + + $isAdmin = $helper->isUserAdmin(); + + if (\class_exists('XoopsFormEditor')) { + if ($isAdmin) { + $descEditor = new \XoopsFormEditor(\ucfirst($options['name']), $helper->getConfig('editorAdmin'), $options, $nohtml = false, $onfailure = 'textarea'); + } else { + $descEditor = new \XoopsFormEditor(\ucfirst($options['name']), $helper->getConfig('editorUser'), $options, $nohtml = false, $onfailure = 'textarea'); + } + } else { + $descEditor = new \XoopsFormDhtmlTextArea(\ucfirst($options['name']), $options['name'], $options['value'], 100, 100); + } + + return $descEditor; + } + + /** + * @param string $content text whose stripped tags become the page meta keywords + */ + public static function metaKeywords($content): void + { + global $xoopsTpl, $xoTheme; + $myts = \MyTextSanitizer::getInstance(); + $content = $myts->undoHtmlSpecialChars($myts->displayTarea($content)); + if (null !== $xoTheme && \is_object($xoTheme)) { + $xoTheme->addMeta('meta', 'keywords', \strip_tags($content)); + } else { // Compatibility for old Xoops versions + $xoopsTpl->assign('xoops_metaKeywords', \strip_tags($content)); + } + } + + /** + * @param string $content text whose stripped tags become the page meta description + */ + public static function metaDescription($content): void + { + global $xoopsTpl, $xoTheme; + $myts = \MyTextSanitizer::getInstance(); + $content = $myts->undoHtmlSpecialChars($myts->displayTarea($content)); + if (null !== $xoTheme && \is_object($xoTheme)) { + $xoTheme->addMeta('meta', 'description', \strip_tags($content)); + } else { // Compatibility for old Xoops versions + $xoopsTpl->assign('xoops_metaDescription', \strip_tags($content)); + } + } +} diff --git a/src/Common/PaginationState.php b/src/Common/PaginationState.php new file mode 100644 index 0000000..4bf0e84 --- /dev/null +++ b/src/Common/PaginationState.php @@ -0,0 +1,122 @@ += 1) + * @param int $currentPage requested page, 1-based (clamped to [1, pageCount]) + * @param int $pageLimit page links per band (coerced to >= 1) + */ + public function __construct(int $total, int $limit = 20, int $currentPage = 1, int $pageLimit = 10) + { + $this->total = \max(0, $total); + $this->limit = $limit > 0 ? $limit : 20; + $this->pageLimit = $pageLimit > 0 ? $pageLimit : 10; + $this->pageCount = (int) \max(1, (int) \ceil($this->total / $this->limit)); + $this->currentPage = \max(1, \min($currentPage, $this->pageCount)); + } + + public function total(): int + { + return $this->total; + } + + public function limit(): int + { + return $this->limit; + } + + public function pageCount(): int + { + return $this->pageCount; + } + + public function currentPage(): int + { + return $this->currentPage; + } + + public function offset(): int + { + return ($this->currentPage - 1) * $this->limit; + } + + public function rangeStart(): int + { + return 0 === $this->total ? 0 : $this->offset() + 1; + } + + public function rangeEnd(): int + { + return 0 === $this->total ? 0 : (int) \min($this->currentPage * $this->limit, $this->total); + } + + public function hasPrevious(): bool + { + return $this->currentPage > 1; + } + + public function hasNext(): bool + { + return $this->currentPage < $this->pageCount; + } + + public function isFirst(): bool + { + return 1 === $this->currentPage; + } + + public function isLast(): bool + { + return $this->currentPage === $this->pageCount; + } + + public function band(): int + { + return (int) \ceil($this->currentPage / $this->pageLimit); + } + + public function bandStart(): int + { + return ($this->band() - 1) * $this->pageLimit + 1; + } + + public function bandEnd(): int + { + return (int) \min($this->pageCount, $this->band() * $this->pageLimit); + } + + public function pages(): array + { + return \range($this->bandStart(), $this->bandEnd()); + } + + public function limitClause(): string + { + return \sprintf(' LIMIT %d, %d', $this->offset(), $this->limit); + } +} diff --git a/src/Common/PaginationStateInterface.php b/src/Common/PaginationStateInterface.php new file mode 100644 index 0000000..5ad2044 --- /dev/null +++ b/src/Common/PaginationStateInterface.php @@ -0,0 +1,66 @@ += 1). */ + public function pageCount(): int; + + /** Current page, 1-based, clamped to [1, pageCount]. */ + public function currentPage(): int; + + /** Zero-based row offset for the current page (for SQL LIMIT / OFFSET). */ + public function offset(): int; + + /** 1-based index of the first row shown on the current page (0 when empty). */ + public function rangeStart(): int; + + /** 1-based index of the last row shown on the current page (0 when empty). */ + public function rangeEnd(): int; + + public function hasPrevious(): bool; + + public function hasNext(): bool; + + public function isFirst(): bool; + + public function isLast(): bool; + + /** Current page band (group of up to pageLimit page links). */ + public function band(): int; + + /** First page number in the current band. */ + public function bandStart(): int; + + /** Last page number in the current band. */ + public function bandEnd(): int; + + /** + * The page numbers in the current band. + * + * @return list + */ + public function pages(): array; + + /** A safe `LIMIT offset, length` clause (no ORDER BY, no user input). */ + public function limitClause(): string; +} diff --git a/src/Common/Paginator.php b/src/Common/Paginator.php new file mode 100644 index 0000000..8e7ea5f --- /dev/null +++ b/src/Common/Paginator.php @@ -0,0 +1,287 @@ +` fragments (left / center / right) plus a + * `LIMIT` clause, mirroring the array shape the legacy `Utility::getPageBar()` + * consumed, so it is a drop-in replacement. Markup uses HTML entities, not + * images, so it needs no asset files. + * + * @category Module + * @package mtools + * @author Mamba + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://github.com/mambax7/mtools + * + * @deprecated Since the xoops/smartyextensions adoption: render pagination in + * templates with the `render_pagination` Smarty plugin + * (NavigationExtension, Bootstrap 5) instead. Kept for BC for any + * PHP-side caller that still needs limit/offset data. + * + * NOT a promotion-ready public API as written: it reads + * $_SERVER['PHP_SELF'], builds hrefs by hand, returns raw SQL LIMIT + * fragments, and accepts an arbitrary $orderSql fragment (caller-trust + * only — never pass user input to it). Before any promotion to + * xoops/helpers it must split page-math from rendering/SQL, use + * http_build_query, escape final hrefs, and drop the free-form ORDER BY. + * + * The page-math half of that split is DONE: the pure, side-effect-free + * {@see PaginationState} (the promotion candidate) now does the counting; + * this class is the legacy renderer that delegates to it. + */ +class Paginator +{ + /** Rows per page. */ + public int $limit = 20; + + /** Page links shown per "page band". */ + public int $pageLimit = 10; + + /** Target page (script) the links point to. */ + public string $toPage = ''; + + /** Extra string appended to each generated URL (e.g. an anchor). */ + public string $urlOther = ''; + + /** Query-string parameter that carries the current page number. */ + public string $urlPage = 'g2p'; + + /** Current page number (1-based). */ + public int $current = 1; + + /** Total number of pages. */ + public int $pTotal = 0; + + /** Current page band. */ + public int $pCurrent = 1; + + /** Preserved query string (without the page parameter). */ + public string $queryStr = ''; + + /** '?' or '&' glue for appending the page parameter. */ + public string $glue = '?'; + + /** + * @param int $total total number of rows in the unpaginated result + * @param string $orderSql optional ORDER BY fragment placed before the LIMIT clause + */ + public function __construct( + public int $total, + int $limit = 20, + int $pageLimit = 10, + public string $orderSql = '' + ) { + $this->limit = $limit > 0 ? $limit : 20; + $this->pageLimit = $pageLimit > 0 ? $pageLimit : 10; + $this->toPage = $_SERVER['PHP_SELF'] ?? ''; + } + + public function setToPage(string $page = ''): void + { + $this->toPage = $page; + } + + public function setUrlOther(string $other = ''): void + { + $this->urlOther = $other; + } + + /** + * Re-read state from the request and recompute derived counters. + */ + public function init(): void + { + $this->queryStr = $this->processQuery([$this->urlPage]); + $this->glue = ('' === $this->queryStr) ? '?' : '&'; + + $requested = isset($_GET[$this->urlPage]) ? (int) $_GET[$this->urlPage] : 1; + + // Delegate the page math to the pure, side-effect-free PaginationState; this class + // keeps only the request-reading (above) and the Bootstrap markup (below). + $state = new PaginationState($this->total, $this->limit, $requested, $this->pageLimit); + $this->pTotal = $state->pageCount(); + $this->current = $state->currentPage(); + $this->pCurrent = $state->band(); + } + + /** + * Rebuild the current query string minus the page parameter, escaping each + * key/value so the result is safe to embed in an href. + * + * @param list $usedQuery parameter names to drop + */ + public function processQuery(array $usedQuery): string + { + $queryString = $_SERVER['QUERY_STRING'] ?? ''; + \parse_str($queryString, $queryVars); + + $filtered = []; + foreach ($queryVars as $key => $value) { + if (\in_array($key, $usedQuery, true)) { + continue; + } + $safeKey = \htmlspecialchars((string) $key, \ENT_QUOTES, 'UTF-8'); + if (\is_array($value)) { + foreach ($value as $arrayKey => $arrayValue) { + $safeValue = \htmlspecialchars((string) $arrayValue, \ENT_QUOTES, 'UTF-8'); + if (\is_numeric($arrayKey)) { + $filtered[] = $safeKey . '[]=' . $safeValue; + } else { + $safeArrayKey = \htmlspecialchars((string) $arrayKey, \ENT_QUOTES, 'UTF-8'); + $filtered[] = $safeKey . '[' . $safeArrayKey . ']=' . $safeValue; + } + } + } else { + $safeValue = \htmlspecialchars((string) $value, \ENT_QUOTES, 'UTF-8'); + $filtered[] = $safeKey . '=' . $safeValue; + } + } + + return empty($filtered) ? '' : '?' . \implode('&', $filtered); + } + + /** + * The `... LIMIT start, length` clause for the current page. + */ + public function sqlQuery(): string + { + $rowStart = ($this->current - 1) * $this->limit; + if ($rowStart < 0) { + $rowStart = 0; + } + $limit = '' !== $this->orderSql ? " {$this->orderSql} LIMIT {$rowStart}, {$this->limit}" : " LIMIT {$rowStart}, {$this->limit}"; + + return $limit; + } + + /** + * Build a Bootstrap pagination bar. + * + * @return array{center:string,left:string,right:string,current:int,total:int,start:int,end:int,sql:string} + */ + public function makeBootStrapBar(string $urlPage = 'g2p', string $bootstrap = '4'): array + { + if ('' !== $urlPage) { + $this->urlPage = $urlPage; + } + $this->init(); + + $back = self::label('_MA_MTOOLS_BACK_PAGE', 'Previous'); + $next = self::label('_MA_MTOOLS_NEXT_PAGE', 'Next'); + $firstLb = self::label('_MA_MTOOLS_FIRST_PAGE', 'First'); + $lastLb = self::label('_MA_MTOOLS_LAST_PAGE', 'Last'); + $curLb = self::label('_MA_MTOOLS_CURRENT_PAGE', 'current page'); + + $loadtime = $this->urlOther; + $start = ($this->pCurrent - 1) * $this->pageLimit + 1; + $end = (int) \min($this->pTotal, $this->pCurrent * $this->pageLimit); + + $center = ''; + for ($i = $start; $i <= $end; $i++) { + $active = $i === $this->current ? ' active' : ''; + $ariaCurrent = $i === $this->current ? ' aria-current="page"' : ''; + $srOnly = $i === $this->current ? ' (' . $curLb . ')' : ''; + $center .= \sprintf( + '
  • %d%s
  • ', + $active, + $ariaCurrent, + $this->pageHref($i, $loadtime), + $i, + $srOnly + ); + } + + $first = $this->current <= 1 + ? $this->disabledItem('«', $firstLb) + : $this->linkItem(1, $loadtime, '«', $firstLb); + + $left = $this->current <= 1 + ? $this->disabledItem('‹', $back) + : $this->linkItem($this->current - 1, $loadtime, '‹', $back); + + $right = $this->current >= $this->pTotal + ? $this->disabledItem('›', $next) + : $this->linkItem($this->current + 1, $loadtime, '›', $next); + + $last = $this->current >= $this->pTotal + ? $this->disabledItem('»', $lastLb) + : $this->linkItem($this->pTotal, $loadtime, '»', $lastLb); + + return [ + 'center' => $center, + 'left' => $first . $left, + 'right' => $right . $last, + 'current' => $this->current, + 'total' => $this->pTotal, + 'start' => ($this->current - 1) * $this->limit + 1, + 'end' => (int) \min($this->current * $this->limit, $this->total), + 'sql' => $this->sqlQuery(), + ]; + } + + /** + * Plain (non-Bootstrap) text bar, same array shape as makeBootStrapBar(). + * + * @return array{center:string,left:string,right:string,sql:string} + */ + public function makeBar(): array + { + $bar = $this->makeBootStrapBar($this->urlPage); + + return [ + 'center' => \strip_tags($bar['center'], ''), + 'left' => \strip_tags($bar['left'], ''), + 'right' => \strip_tags($bar['right'], ''), + 'sql' => $bar['sql'], + ]; + } + + private function pageHref(int $page, string $loadtime): string + { + return $this->toPage . $this->queryStr . $this->glue . $this->urlPage . '=' . $page . $loadtime; + } + + private function linkItem(int $page, string $loadtime, string $glyph, string $label): string + { + return \sprintf( + '
  • ', + $this->pageHref($page, $loadtime), + \htmlspecialchars($label, \ENT_QUOTES, 'UTF-8'), + $glyph + ); + } + + private function disabledItem(string $glyph, string $label): string + { + return \sprintf( + '
  • %s
  • ', + \htmlspecialchars($label, \ENT_QUOTES, 'UTF-8'), + $glyph + ); + } + + /** + * Return a language constant if defined, otherwise the English fallback. + * Keeps the class self-contained — no language file must be loaded first. + */ + private static function label(string $constant, string $fallback): string + { + return \defined($constant) ? (string) \constant($constant) : $fallback; + } +} diff --git a/src/Common/ResizeRequest.php b/src/Common/ResizeRequest.php new file mode 100644 index 0000000..323bf2a --- /dev/null +++ b/src/Common/ResizeRequest.php @@ -0,0 +1,45 @@ +targetPath; + + if (!\extension_loaded('gd')) { + return ResizeResult::failure($target, 'The GD extension is not available.'); + } + if (!\is_file($request->sourcePath)) { + return ResizeResult::failure($target, 'Source image not found: ' . $request->sourcePath); + } + $info = @\getimagesize($request->sourcePath); + if (false === $info) { + return ResizeResult::failure($target, 'Not a readable image: ' . $request->sourcePath); + } + $mime = (string) $info['mime']; + + $image = $this->createImage($request->sourcePath, $mime); + if (!$image instanceof \GdImage) { + return ResizeResult::failure($target, 'Unsupported image format: ' . $mime); + } + if ('image/jpeg' === $mime) { + $image = $this->applyExifOrientation($image, $request->sourcePath); + } + + $srcW = \imagesx($image); + $srcH = \imagesy($image); + + $outputMime = $request->outputMime ?? $mime; + $canvas = $this->render($request, $image, $srcW, $srcH, $outputMime); + if (!$this->save($canvas, $target, $outputMime, $request->quality)) { + return ResizeResult::failure($target, 'Failed to write the target image: ' . $target); + } + + return ResizeResult::success($target, \imagesx($canvas), \imagesy($canvas)); + } + + private function createImage(string $path, string $mime): ?\GdImage + { + $image = match ($mime) { + 'image/png' => @\imagecreatefrompng($path), + 'image/jpeg' => @\imagecreatefromjpeg($path) ?: @\imagecreatefromstring((string) @\file_get_contents($path)), + 'image/gif' => @\imagecreatefromgif($path), + 'image/webp' => \function_exists('imagecreatefromwebp') ? @\imagecreatefromwebp($path) : false, + default => false, + }; + + return $image instanceof \GdImage ? $image : null; + } + + /** + * Rotate a JPEG to its EXIF "Orientation" so portrait phone photos are upright. + * Handles the three common rotations (3/6/8); leaves the rarer flip cases as-is. + */ + private function applyExifOrientation(\GdImage $image, string $path): \GdImage + { + if (!\function_exists('exif_read_data')) { + return $image; + } + $exif = @\exif_read_data($path); + $orientation = \is_array($exif) ? (int) ($exif['Orientation'] ?? 0) : 0; + + $rotated = match ($orientation) { + 3 => \imagerotate($image, 180, 0), + 6 => \imagerotate($image, -90, 0), + 8 => \imagerotate($image, 90, 0), + default => $image, + }; + + return $rotated instanceof \GdImage ? $rotated : $image; + } + + private function render(ResizeRequest $r, \GdImage $src, int $srcW, int $srcH, string $mime): \GdImage + { + $maxW = \max(1, $r->maxWidth); + $maxH = \max(1, $r->maxHeight); + + if (ResizeRequest::FIT_COVER === $r->fit) { + // Scale to cover the box, then centre-crop to exactly maxW x maxH. + $ratio = \max($maxW / $srcW, $maxH / $srcH); + $resizeW = \max(1, (int) \ceil($srcW * $ratio)); + $resizeH = \max(1, (int) \ceil($srcH * $ratio)); + + $scaled = $this->newCanvas($resizeW, $resizeH, $mime); + \imagecopyresampled($scaled, $src, 0, 0, 0, 0, $resizeW, $resizeH, $srcW, $srcH); + + $cropX = (int) \max(0, \floor(($resizeW - $maxW) / 2)); + $cropY = (int) \max(0, \floor(($resizeH - $maxH) / 2)); + $canvas = $this->newCanvas($maxW, $maxH, $mime); + \imagecopy($canvas, $scaled, 0, 0, $cropX, $cropY, $maxW, $maxH); + + return $canvas; + } + + if (ResizeRequest::FIT_STRETCH === $r->fit) { + $newW = $maxW; + $newH = $maxH; + } else { // FIT_INSIDE + $ratio = \min($maxW / $srcW, $maxH / $srcH); + if (!$r->allowUpscale) { + $ratio = \min($ratio, 1.0); + } + $newW = \max(1, (int) \round($srcW * $ratio)); + $newH = \max(1, (int) \round($srcH * $ratio)); + } + + $canvas = $this->newCanvas($newW, $newH, $mime); + \imagecopyresampled($canvas, $src, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH); + + return $canvas; + } + + private function newCanvas(int $width, int $height, string $mime): \GdImage + { + $canvas = \imagecreatetruecolor(\max(1, $width), \max(1, $height)); + if (\in_array($mime, ['image/png', 'image/gif', 'image/webp'], true)) { + \imagealphablending($canvas, false); + \imagesavealpha($canvas, true); + $transparent = \imagecolorallocatealpha($canvas, 0, 0, 0, 127); + if (false !== $transparent) { + \imagefilledrectangle($canvas, 0, 0, $width, $height, $transparent); + } + } + + return $canvas; + } + + private function save(\GdImage $image, string $path, string $mime, int $quality): bool + { + $quality = \max(0, \min(100, $quality)); + + return match ($mime) { + 'image/png' => \imagepng($image, $path, $this->pngLevel($quality)), + 'image/jpeg' => \imagejpeg($image, $path, $quality), + 'image/gif' => \imagegif($image, $path), + 'image/webp' => \function_exists('imagewebp') && \imagewebp($image, $path, $quality), + default => false, + }; + } + + /** Map quality 0–100 to PNG zlib compression 9–0 (higher quality => less compression). */ + private function pngLevel(int $quality): int + { + return \max(0, \min(9, 9 - (int) \round($quality / 100 * 9))); + } +} diff --git a/src/Common/ServerStats.php b/src/Common/ServerStats.php new file mode 100644 index 0000000..ec326d9 --- /dev/null +++ b/src/Common/ServerStats.php @@ -0,0 +1,115 @@ + + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + */ +trait ServerStats +{ + /** + * serverStats() + */ + public static function getServerStats(): string + { + $moduleDirName = self::consumerDirnameForStats(); + \xoops_loadLanguage('common', $moduleDirName); + $label = static fn (string $suffix): string => self::serverStatsLabel($moduleDirName, $suffix); + $html = ''; + // $sql = 'SELECT metavalue'; + // $sql .= ' FROM ' . $GLOBALS['xoopsDB']->prefix('wfdownloads_meta'); + // $sql .= " WHERE metakey='version' LIMIT 1"; + // $query = $GLOBALS['xoopsDB']->query($sql); + // list($meta) = (($GLOBALS['xoopsDB']->isResultSet($query) && ($query instanceof \mysqli_result)) ? $GLOBALS['xoopsDB']->fetchRow($query) : false); + $html .= '
    '; + $html .= "" . $label('IMAGEINFO') . ''; + $html .= "
    "; + // $html .= '
    ' . constant('_CO_MTOOLS_METAVERSION') . $meta . "
    "; + // $html .= "
    "; + // $html .= "
    "; + $html .= '
    ' . $label('SPHPINI') . '
    '; + $html .= '
      '; + + if (\function_exists('gd_info')) { + $html .= '
    • ' . $label('GDLIBSTATUS') . '' . $label('GDON') . ''; + $gdlib = \gd_info(); + if (!empty(($gdlib))) { + $html .= '
    • ' . $label('GDLIBVERSION') . '' . self::escapeServerStat((string) $gdlib['GD Version']) . ''; + } + } else { + $html .= '
    • ' . $label('GDLIBSTATUS') . '' . $label('GDOFF') . ''; + } + + // $safemode = ini_get('safe_mode') ? constant('_CO_MTOOLS_ON') . constant('_CO_MTOOLS_SAFEMODEPROBLEMS : constant('_CO_MTOOLS_OFF'); + // $html .= '
    • ' . constant('_CO_MTOOLS_SAFEMODESTATUS . $safemode; + + // $registerglobals = (!ini_get('register_globals')) ? "" . constant('_CO_MTOOLS_OFF') . '' : "" . constant('_CO_MTOOLS_ON') . ''; + // $html .= '
    • ' . constant('_CO_MTOOLS_REGISTERGLOBALS . $registerglobals; + + $downloads = \ini_get('file_uploads') ? '' . $label('ON') . '' : '' . $label('OFF') . ''; + $html .= '
    • ' . $label('SERVERUPLOADSTATUS') . $downloads; + + $html .= '
    • ' . $label('MAXUPLOADSIZE') . ' ' . self::escapeServerStat((string) \ini_get('upload_max_filesize')) . ''; + $html .= '
    • ' . $label('MAXPOSTSIZE') . ' ' . self::escapeServerStat((string) \ini_get('post_max_size')) . ''; + $html .= '
    • ' . $label('MEMORYLIMIT') . ' ' . self::escapeServerStat((string) \ini_get('memory_limit')) . ''; + $html .= '
    '; + $html .= '
      '; + $html .= '
    • ' . $label('SERVERPATH') . ' ' . self::escapeServerStat((string) XOOPS_ROOT_PATH) . ''; + $html .= '
    '; + $html .= '
    '; + $html .= $label('UPLOADPATHDSC'); + $html .= '
    '; + $html .= '

    '; + + return $html; + } + + private static function serverStatsLabel(string $moduleDirName, string $suffix): string + { + $moduleKey = \mb_strtoupper((string) \preg_replace('/[^A-Za-z0-9]+/', '_', $moduleDirName)); + $consumerConstant = '_CO_' . $moduleKey . '_' . $suffix; + if (\defined($consumerConstant)) { + return (string) \constant($consumerConstant); + } + + \Xoops\ModuleTools\Internal\PackageLanguage::load('common'); + + $fallbackConstant = '_CO_MTOOLS_' . $suffix; + + return \defined($fallbackConstant) ? (string) \constant($fallbackConstant) : $suffix; + } + + private static function escapeServerStat(string $value): string + { + return \htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + } + + /** @legacy-global-accessor */ + private static function consumerDirnameForStats(): string + { + $calledClass = static::class; + if (preg_match('/^XoopsModules\\\\([^\\\\]+)/', $calledClass, $matches)) { + return strtolower($matches[1]); + } + + return isset($GLOBALS['xoopsModule']) && $GLOBALS['xoopsModule'] instanceof \XoopsModule + ? (string)$GLOBALS['xoopsModule']->getVar('dirname') + : 'mtools'; + } +} diff --git a/src/Common/SocialBookmarks.php b/src/Common/SocialBookmarks.php new file mode 100644 index 0000000..0bc7073 --- /dev/null +++ b/src/Common/SocialBookmarks.php @@ -0,0 +1,85 @@ + $networks subset/order of self::DEFAULT_NETWORKS + */ + public static function render(string $url, string $title, array $networks = []): string + { + $url = \trim($url); + if ('' === $url) { + return ''; + } + $networks = [] === $networks ? self::DEFAULT_NETWORKS : $networks; + + $u = \rawurlencode($url); + $t = \rawurlencode($title); + + // [label, href, FontAwesome-4-safe icon, brand colour] + $catalog = [ + 'x' => ['X / Twitter', 'https://twitter.com/intent/tweet?url=' . $u . '&text=' . $t, 'fa-twitter', '#1da1f2'], + 'facebook' => ['Facebook', 'https://www.facebook.com/sharer/sharer.php?u=' . $u, 'fa-facebook', '#1877f2'], + 'linkedin' => ['LinkedIn', 'https://www.linkedin.com/sharing/share-offsite/?url=' . $u, 'fa-linkedin', '#0a66c2'], + 'whatsapp' => ['WhatsApp', 'https://api.whatsapp.com/send?text=' . $t . '%20' . $u, 'fa-whatsapp', '#25d366'], + 'email' => ['Email', 'mailto:?subject=' . $t . '&body=' . $u, 'fa-envelope', '#ea4335'], + ]; + + $esc = static fn(string $s): string => \htmlspecialchars($s, \ENT_QUOTES, 'UTF-8'); + + // Pronounced circular brand buttons with white glyphs. + $circle = 'display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;' + . 'border-radius:50%;color:#fff;text-decoration:none;transition:opacity .15s'; + $hover = 'onmouseover="this.style.opacity=0.85" onmouseout="this.style.opacity=1"'; + + $html = ''; + foreach ($networks as $key) { + if ('copy' === $key) { + $html .= '' + . ''; + continue; + } + if (!isset($catalog[$key])) { + continue; + } + [$label, $href, $icon, $color] = $catalog[$key]; + $html .= '' + . ''; + } + $html .= ''; + + return $html; + } +} diff --git a/src/Common/StandardConfig.php b/src/Common/StandardConfig.php new file mode 100644 index 0000000..fced14d --- /dev/null +++ b/src/Common/StandardConfig.php @@ -0,0 +1,68 @@ +_ and ..._DESC + * (e.g. pedigree + 'displaySampleButton' -> _MI_PEDIGREE_SHOW_SAMPLE_BUTTON). + * The constant NAME is passed through (XOOPS resolves it when rendering the form), + * so a module just has to define those constants in its modinfo language file. + * + * @api Stable Common-tier API. + * @since 1.3.0 + */ +final class StandardConfig +{ + /** + * config name => [constant-suffix, formtype, valuetype, default] + * + * @var array + */ + private const array CATALOG = [ + 'socialBookmarks' => ['SOCIAL_BOOKMARKS', 'yesno', 'int', 1], + 'displaySampleButton' => ['SHOW_SAMPLE_BUTTON', 'yesno', 'int', 1], + 'displayDeveloperTools' => ['SHOW_DEV_TOOLS', 'yesno', 'int', 0], + ]; + + /** + * Append the requested standard config items to $modversion['config']. + * + * @param array $modversion the module's $modversion (by reference) + * @param string $dirname module dirname (e.g. 'pedigree') + * @param list $include which items, in order (default: all) + * @param array $defaults override defaults per config name + */ + public static function append(array &$modversion, string $dirname, array $include = [], array $defaults = []): void + { + $upper = \mb_strtoupper($dirname); + $include = [] === $include ? \array_keys(self::CATALOG) : $include; + + foreach ($include as $name) { + if (!isset(self::CATALOG[$name])) { + continue; + } + [$suffix, $formtype, $valuetype, $default] = self::CATALOG[$name]; + $modversion['config'][] = [ + 'name' => $name, + 'title' => '_MI_' . $upper . '_' . $suffix, + 'description' => '_MI_' . $upper . '_' . $suffix . '_DESC', + 'formtype' => $formtype, + 'valuetype' => $valuetype, + 'default' => $defaults[$name] ?? $default, + ]; + } + } +} diff --git a/src/Common/SysUtility.php b/src/Common/SysUtility.php new file mode 100644 index 0000000..eaa7423 --- /dev/null +++ b/src/Common/SysUtility.php @@ -0,0 +1,283 @@ + + * @author Mamba + */ + +/** + * Class SysUtility + * + * Backward-compatible FACADE over the focused Common helpers. Historically this class + * was a grab-bag; its logic now lives in {@see Text} (pure string helpers), + * {@see Db} (database helpers, explicit handle) and {@see Output} (admin/UI output). + * Every method below forwards to one of those, keeping its original signature so + * existing `$utility::method()` calls — including from consumer subclasses that + * extend this class — keep working unchanged. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + */ +class SysUtility +{ + use VersionChecks; + + //checkVerXoops, checkVerPhp Traits + + use ServerStats; + + // getServerStats Trait + + use ModuleStats; + + // getModuleStats Trait + + use FilesManagement; + + // Files Management Trait + + //--------------- Common module methods ----------------------------- + + /** + * Access the only instance of this class + * + * @return object + */ + public static function getInstance() + { + static $instance; + $instance ??= new static(); + + return $instance; + } + + /** + * Forwards to {@see Output::selectSorting()} with the CONSUMER's resolved Helper. + * + * @param $text + * @param $form_sort + * @param \Xmf\Module\Helper|null $helper + */ + public static function selectSorting($text, $form_sort, $helper = null): string + { + return Output::selectSorting($text, $form_sort, $helper ?? self::consumerHelper()); + } + + /***************Blocks***************/ + + /** + * Forwards to {@see Db::blockAddCatSelect()}. + * + * @param array $cats + */ + public static function blockAddCatSelect($cats): string + { + return Db::blockAddCatSelect($cats); + } + + /** + * Forwards to {@see Output::metaKeywords()}. + * + * @param $content + */ + public static function metaKeywords($content): void + { + Output::metaKeywords($content); + } + + /** + * Forwards to {@see Output::metaDescription()}. + * + * @param $content + */ + public static function metaDescription($content): void + { + Output::metaDescription($content); + } + + /** + * Forwards to {@see Db::enumerate()} using the global XOOPS database handle. + * + * @param $tableName + * @param $columnName + */ + public static function enumerate($tableName, $columnName): array + { + return Db::enumerate(self::runtimeDatabase(), (string)$tableName, (string)$columnName); + } + + /** + * Forwards to {@see Text::truncateHtml()}. + * + * @param string $text String to truncate. + * @param int $length Length of returned string, including ellipsis. + * @param string $ending Ending to be appended to the trimmed string. + * @param bool $exact If false, $text will not be cut mid-word + * @param bool $considerHtml If true, HTML tags would be handled correctly + * + * @return string Trimmed string. + */ + public static function truncateHtml($text, $length = 100, $ending = '...', $exact = false, $considerHtml = true): string + { + return Text::truncateHtml((string)$text, (int)$length, (string)$ending, (bool)$exact, (bool)$considerHtml); + } + + /** + * Forwards to {@see Output::getEditor()} with the CONSUMER's resolved Helper. + * + * @param \Xmf\Module\Helper $helper + * @param array|null $options + * @return \XoopsFormDhtmlTextArea|\XoopsFormEditor + */ + public static function getEditor($helper = null, $options = null) + { + return Output::getEditor($helper ?? self::consumerHelper(), $options); + } + + /** + * Resolve the CONSUMER's module Helper (not mtools' own). + * + * A consumer extends this class as `XoopsModules\\Utility`, so late + * static binding lets us derive its sibling `XoopsModules\\Helper`. + * Callers using the base facade directly must pass a module helper explicitly; + * ModuleTools itself has no module identity, configuration, or permissions. + * + * @return \Xmf\Module\Helper + * @throws \LogicException when the consumer has no module helper + */ + private static function consumerHelper() + { + $class = static::class; + $lastSlash = \strrpos($class, '\\'); + + if (false !== $lastSlash) { + $helperClass = \substr($class, 0, $lastSlash) . '\\Helper'; + + if (\class_exists($helperClass) && \method_exists($helperClass, 'getInstance')) { + return $helperClass::getInstance(); + } + } + + throw new \LogicException( + 'A consumer XMF module helper is required; pass it explicitly or provide a sibling Helper class.', + ); + } + + /** + * Forwards to {@see Db::fieldExists()} using the global XOOPS database handle. + * + * @param $fieldname + * @param $table + */ + public static function fieldExists(string $fieldname, string $table): bool + { + return Db::fieldExists(self::runtimeDatabase(), $fieldname, $table); + } + + /** + * Forwards to {@see Db::cloneRecord()} using the global XOOPS database handle. + * + * @param array|string $tableName + * @param string $id_field + * @param int $id + * + * @return mixed + */ + public static function cloneRecord($tableName, $id_field, $id) + { + return Db::cloneRecord(self::runtimeDatabase(), $tableName, (string)$id_field, (int)$id); + } + + /** + * Function responsible for checking if a directory exists, we can also write in and create an index.html file + * + * @param string $folder The full path of the directory to check + */ + public static function prepareFolder($folder): void + { + if (!self::isSafeFilesystemPath((string)$folder)) { + throw new \RuntimeException(\sprintf('Refusing unsafe directory path: %s', $folder)); + } + + if (!\is_dir($folder) && !@\mkdir($folder, 0755, true) && !\is_dir($folder)) { + throw new \RuntimeException(\sprintf('Unable to create the %s directory', $folder)); + } + + $indexFile = rtrim((string)$folder, '/\\') . '/index.html'; + if (!is_file($indexFile)) { + file_put_contents($indexFile, ''); + } + } + + /** + * Check if dB table exists + * + * @deprecated Use Xmf\Database\Tables instead. Forwards to {@see Db::tableExists()}. + * + * @param string $tablename dB tablename with prefix + * @return bool true if table exists + */ + public static function tableExists(string $tablename): bool + { + return Db::tableExists(self::runtimeDatabase(), $tablename); + } + + /** + * Query and check if the result is a valid result set. Forwards to {@see Db::queryAndCheck()}. + * + * @param \XoopsMySQLDatabase $xoopsDB XOOPS Database + * @param string $sql a valid MySQL query + * @param int $limit number of records to return + * @param int $start offset of first record to return + * + * @return \mysqli_result query result + */ + public static function queryAndCheck(\XoopsMySQLDatabase $xoopsDB, string $sql, $limit = 0, $start = 0): \mysqli_result + { + return Db::queryAndCheck($xoopsDB, $sql, (int)$limit, (int)$start); + } + + /** + * QueryF and check if the result is a valid result set + * + * @deprecated 2.7.0 queryF() bypassed Protector's SQL inspection. For SELECTs use + * {@see self::queryAndCheck()} (query()); for writes/DDL call $db->exec() + * directly. This wrapper now delegates to {@see Db::queryFAndCheck()} and + * is retained only for backward compatibility. + * + * @param \XoopsMySQLDatabase $xoopsDB XOOPS Database + * @param string $sql a valid MySQL query + * @param int $limit number of records to return + * @param int $start offset of first record to return + * + * @return \mysqli_result query result + */ + public static function queryFAndCheck(\XoopsMySQLDatabase $xoopsDB, string $sql, $limit = 0, $start = 0): \mysqli_result + { + return Db::queryFAndCheck($xoopsDB, $sql, (int)$limit, (int)$start); + } + + /** @legacy-global-accessor */ + private static function runtimeDatabase(): mixed + { + return $GLOBALS['xoopsDB'] ?? null; + } +} diff --git a/src/Common/TestdataButtons.php b/src/Common/TestdataButtons.php new file mode 100644 index 0000000..82b84a2 --- /dev/null +++ b/src/Common/TestdataButtons.php @@ -0,0 +1,253 @@ +' + . '.xo-buttons{margin:6px 0 14px}' + . '.xo-buttons a{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;margin:0 0 6px;' + . 'margin-inline-end:6px;border:1px solid #cdd5df;border-radius:6px;background:#f8f9fa;color:#212529;' + . 'text-decoration:none;font-weight:500;line-height:1.2}' + . '.xo-buttons a:hover{background:#eef1f4;text-decoration:none}' + . '.xo-buttons img{vertical-align:middle;border:0}' + . ''; + } + + /** + * Load the test button configuration + * + * @param \Xmf\Module\Admin $adminObject + * + * @return void + */ + public static function loadButtonConfig( + $adminObject, + \Xmf\Module\Helper $helper, + ?int $displayFlag = null, + ): void { + self::emitButtonStyle(); + + // These shared labels belong to ModuleTools rather than to the consumer + // module, so load the package catalog without inventing a module Helper. + PackageLanguage::load('common', self::runtimeLanguage()); + + $moduleDirName = $helper->dirname(); + $moduleDirNameUpper = \mb_strtoupper($moduleDirName); + + // Config source: a consumer that keeps the toggle in XOOPS preferences passes + // the flag in ($displayFlag); otherwise read its own config/admin.yml. + if (null !== $displayFlag) { + $displaySampleButton = $displayFlag; + } else { + $yamlFile = $helper->path('/config/admin.yml'); + /** @var array $config */ + $config = Yaml::readWrapped($yamlFile); // work with phpmyadmin YAML dumps + $displaySampleButton = $config['displaySampleButton'] ?? self::HIDE_BUTTONS; + } + + // CSRF token appended to the toggle links; validated in hide/showButtons() before + // the config write. The toggle is a state change, so it must not be forgeable. + // The request field name is "_REQUEST" (default token name XOOPS_TOKEN). + $tokenParam = '&XOOPS_TOKEN_REQUEST=' . self::runtimeSecurity()->createToken(); + + if (self::SHOW_BUTTONS == $displaySampleButton) { + \xoops_loadLanguage('admin/modulesadmin', 'system'); + $adminObject->addItemButton(\constant('_CO_MTOOLS_LOAD_SAMPLEDATA'), $helper->url('testdata/index.php?op=load'), 'add'); + $adminObject->addItemButton(\constant('_CO_MTOOLS_SAVE_SAMPLEDATA'), $helper->url('testdata/index.php?op=save'), 'add'); + $adminObject->addItemButton(\constant('_CO_MTOOLS_CLEAR_SAMPLEDATA'), $helper->url('testdata/index.php?op=clear'), 'alert'); + // $adminObject->addItemButton(constant('_CO_MTOOLS_EXPORT_SCHEMA'), $helper->url( 'testdata/index.php?op=exportschema'), 'add'); + $adminObject->addItemButton(\constant('_CO_MTOOLS_HIDE_SAMPLEDATA_BUTTONS'), '?op=hide_buttons' . $tokenParam, 'delete'); + } else { + $adminObject->addItemButton(\constant('_CO_MTOOLS_SHOW_SAMPLEDATA_BUTTONS'), '?op=show_buttons' . $tokenParam, 'add'); + // $displaySampleButton = $config['displaySampleButton']; + } + } + + //$modhelper->url('admin/index.php?op=show_buttons') + + /** + * Return self-contained, always-visible sample-data buttons as plain HTML. + * + * Unlike {@see loadButtonConfig()} (which depends on Xmf's admin item-button + * rendering + jQuery-UI chrome that some cpanel themes don't ship — leaving + * the buttons invisible), this echoes real, styled `` buttons that render + * in every admin theme. The consumer simply `echo`s the return value. + * + * @param \Xmf\Module\Helper $helper consumer module helper + * @param int|null $displayFlag 1 = show the data buttons, 0 = show + * only the "Show" toggle; null = read + * the consumer's config/admin.yml. + */ + public static function renderHtml(\Xmf\Module\Helper $helper, ?int $displayFlag = null): string + { + PackageLanguage::load('common', self::runtimeLanguage()); + + if (null !== $displayFlag) { + $show = self::SHOW_BUTTONS === $displayFlag; + } else { + /** @var array $config */ + $config = Yaml::readWrapped($helper->path('/config/admin.yml')); + $show = self::SHOW_BUTTONS === ($config['displaySampleButton'] ?? self::HIDE_BUTTONS); + } + + $token = self::runtimeSecurity()->createToken(); + $btn = static function (string $label, string $href, string $variant = ''): string { + $cls = 'xo-sd-btn' . ('' !== $variant ? ' xo-sd-btn--' . $variant : ''); + return '' + . \htmlspecialchars($label, \ENT_QUOTES, 'UTF-8') . ''; + }; + + $out = self::sampleDataStyle() . '
    '; + if ($show) { + $out .= $btn(\constant('_CO_MTOOLS_LOAD_SAMPLEDATA'), $helper->url('testdata/index.php?op=load')); + $out .= $btn(\constant('_CO_MTOOLS_SAVE_SAMPLEDATA'), $helper->url('testdata/index.php?op=save')); + $out .= $btn(\constant('_CO_MTOOLS_CLEAR_SAMPLEDATA'), $helper->url('testdata/index.php?op=clear'), 'danger'); + $out .= $btn(\constant('_CO_MTOOLS_HIDE_SAMPLEDATA_BUTTONS'), '?op=hide_buttons&XOOPS_TOKEN_REQUEST=' . $token, 'muted'); + } else { + $out .= $btn(\constant('_CO_MTOOLS_SHOW_SAMPLEDATA_BUTTONS'), '?op=show_buttons&XOOPS_TOKEN_REQUEST=' . $token); + } + + return $out . '
    '; + } + + /** Self-contained button CSS (no theme / jQuery-UI dependency). */ + private static function sampleDataStyle(): string + { + if (self::$styleEmitted) { + return ''; + } + self::$styleEmitted = true; + return ''; + } + + public static function hideButtons(\Xmf\Module\Helper $modhelper): void + { + if (!self::checkToken()) { + \redirect_header($modhelper->url('admin/index.php'), 3, \implode('
    ', self::runtimeSecurity()->getErrors())); + return; + } + $yamlFile = $modhelper->path('config/admin.yml'); + $app = []; + $app['displaySampleButton'] = 0; + Yaml::save($app, $yamlFile); + \redirect_header($modhelper->url('admin/index.php'), 0, ''); + } + + public static function showButtons(\Xmf\Module\Helper $modhelper): void + { + if (!self::checkToken()) { + \redirect_header($modhelper->url('admin/index.php'), 3, \implode('
    ', self::runtimeSecurity()->getErrors())); + return; + } + $yamlFile = $modhelper->path('config/admin.yml'); + $app = []; + $app['displaySampleButton'] = 1; + Yaml::save($app, $yamlFile); + \redirect_header($modhelper->url('admin/index.php'), 0, ''); + } + + /** + * Toggle the sample-data buttons for a consumer that stores `displaySampleButton` + * in its XOOPS module preferences (rather than mtools' config/admin.yml). + * + * CSRF-checked, then writes the module config row and redirects back. + */ + public static function setSampleButtonConfig(\Xmf\Module\Helper $modhelper, int $value): void + { + if (!self::checkToken()) { + \redirect_header($modhelper->url('admin/index.php'), 3, \implode('
    ', self::runtimeSecurity()->getErrors())); + return; + } + $module = $modhelper->getModule(); + if (\is_object($module)) { + $configHandler = xoops_getHandler('config'); + $criteria = new \Criteria('conf_modid', (int) $module->getVar('mid')); + foreach ($configHandler->getConfigs($criteria) as $cfg) { + if ('displaySampleButton' === $cfg->getVar('conf_name')) { + $cfg->setVar('conf_value', $value); + $configHandler->insertConfig($cfg); + break; + } + } + } + \redirect_header($modhelper->url('admin/index.php'), 0, ''); + } + + /** + * Validate the CSRF token carried on the toggle link. + * + * XoopsSecurity::check() (clearIfValid=true) reads XOOPS_TOKEN_REQUEST from POST then + * GET, so it validates the one-time token carried on the GET toggle links produced by + * loadButtonConfig(). + */ + private static function checkToken(): bool + { + $security = self::runtimeSecurity(); + + return is_object($security) && $security->check(); + } + + /** @legacy-global-accessor */ + private static function runtimeSecurity(): mixed + { + return $GLOBALS['xoopsSecurity'] ?? null; + } + + /** @legacy-global-accessor */ + private static function runtimeLanguage(): string + { + $config = $GLOBALS['xoopsConfig'] ?? []; + + return is_array($config) ? (string) ($config['language'] ?? 'english') : 'english'; + } +} diff --git a/src/Common/TestdataSample.php b/src/Common/TestdataSample.php new file mode 100644 index 0000000..7abc673 --- /dev/null +++ b/src/Common/TestdataSample.php @@ -0,0 +1,191 @@ +moduleDirName = $this->modHelper->dirname(); + $this->moduleDirNameUpper = \mb_strtoupper($this->moduleDirName); + $this->modHelper->loadLanguage('common'); + + $this->language = 'english/'; + if (\is_dir(__DIR__ . '/' . $xoopsConfig['language'])) { + $this->language = ((string) ($xoopsConfig['language'] ?? 'english')) . '/'; + } + } + + // XMF TableLoad for SAMPLE data + + public function loadData(): void + { + $utility = new Utility(); + $configurator = new Configurator($this->modHelper->path()); + $tables = $this->modHelper->getModule()->getInfo('tables'); + + // load module tables + foreach ($tables as $table) { + $tabledata = \Xmf\Yaml::readWrapped($this->language . $table . '.yml'); + \Xmf\Database\TableLoad::truncateTable($table); + \Xmf\Database\TableLoad::loadTableFromArray($table, $tabledata); + } + + // load permissions + $table = 'group_permission'; + $tabledata = \Xmf\Yaml::readWrapped($this->language . $table . '.yml'); + $mid = $this->modHelper->getModule()->getVar('mid'); + $this->loadTableFromArrayWithReplace($table, $tabledata, 'gperm_modid', $mid); + + // --- COPY test folder files --------------- + if (\is_array($configurator->copyTestFolders) && \count($configurator->copyTestFolders) > 0) { + // $file = \dirname(__DIR__) . '/testdata/images/'; + foreach (\array_keys($configurator->copyTestFolders) as $i) { + $src = $configurator->copyTestFolders[$i][0]; + $dest = $configurator->copyTestFolders[$i][1]; + $utility::rcopy($src, $dest); + } + } + \redirect_header($this->modHelper->url('admin/index.php'), 1, \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'LOAD_SAMPLEDATA_SUCCESS')); + } + + public function saveData(): void + { + global $xoopsConfig; + $tables = $this->modHelper->getModule()->getInfo('tables'); + + $languageFolder = $this->modHelper->path('testdata/' . $this->language); + if (!\file_exists($languageFolder . '/')) { + Utility::createFolder($languageFolder . '/'); + } + $exportFolder = $languageFolder . '/Exports-' . \date('Y-m-d-H-i-s') . '/'; + Utility::createFolder($exportFolder); + + // save module tables + foreach ($tables as $table) { + TableLoad::saveTableToYamlFile($table, $exportFolder . $table . '.yml'); + } + + // save permissions + $criteria = new \CriteriaCompo(); + $criteria->add(new \Criteria('gperm_modid', $this->modHelper->getModule()->getVar('mid'))); + $skipColumns[] = 'gperm_id'; + TableLoad::saveTableToYamlFile('group_permission', $exportFolder . 'group_permission.yml', $criteria, $skipColumns); + unset($criteria); + + \redirect_header($this->modHelper->url('admin/index.php'), 1, \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'LOAD_SAMPLEDATA_SUCCESS')); + } + + public function exportSchema(): void + { + try { + // TODO set exportSchema + // $migrate = new Migrate($moduleDirName); + // $migrate->saveCurrentSchema(); + // + // redirect_header('../admin/index.php', 1, constant('_CO_MTOOLS_EXPORT_SCHEMA_SUCCESS')); + } catch (\Throwable) { + exit(\constant('_CO_' . $this->moduleDirNameUpper . '_' . 'EXPORT_SCHEMA_ERROR')); + } + } + + public function clearData(): void + { + // Load language files + $this->modHelper->loadLanguage('common'); + $tables = $this->modHelper->getModule()->getInfo('tables'); + // truncate module tables + foreach ($tables as $table) { + \Xmf\Database\TableLoad::truncateTable($table); + } + \redirect_header($this->modHelper->url('admin/index.php'), 1, \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'CLEAR_SAMPLEDATA_OK')); + } + + /** + * loadTableFromArrayWithReplace + * + * @param string $table value which should be used instead of original value of $search + * + * @param array $data array of rows to insert + * Each element of the outer array represents a single table row. + * Each row is an associative array in 'column' => 'value' format. + * @param string $search name of column for which the value should be replaced + * @param $replace + */ + private function loadTableFromArrayWithReplace($table, $data, $search, $replace): void + { + /** @var \XoopsMySQLDatabase $db */ + $db = \XoopsDatabaseFactory::getDatabaseConnection(); + + $prefixedTable = $db->prefix($table); + $count = 0; + + $sql = 'DELETE FROM ' . $prefixedTable . ' WHERE `' . $search . '`=' . $db->quote($replace); + + $db->exec($sql); + + foreach ($data as $row) { + $insertInto = 'INSERT INTO ' . $prefixedTable . ' ('; + $valueClause = ' VALUES ('; + $first = true; + foreach ($row as $column => $value) { + if ($first) { + $first = false; + } else { + $insertInto .= ', '; + $valueClause .= ', '; + } + + if (!preg_match('/^[A-Za-z0-9_]+$/', (string)$column)) { + continue; + } + + $insertInto .= '`' . $column . '`'; + if ($search === $column) { + $valueClause .= $db->quote($replace); + } else { + $valueClause .= $db->quote($value); + } + } + + $sql = $insertInto . ') ' . $valueClause . ')'; + + $result = $db->exec($sql); + if (false !== $result) { + ++$count; + } + } + } +} diff --git a/src/Common/Text.php b/src/Common/Text.php new file mode 100644 index 0000000..bac0b22 --- /dev/null +++ b/src/Common/Text.php @@ -0,0 +1,137 @@ + + * @author Mamba + */ + +/** + * Pure text helpers extracted from {@see SysUtility}. + * + * No request reads, no output, no globals, no database — the strongest XMF candidate + * in the Common tier. {@see SysUtility::truncateHtml()} forwards here for backward + * compatibility, so existing `$utility::truncateHtml(...)` calls keep working. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +final class Text implements TextInterface +{ + /** + * truncateHtml can truncate a string up to a number of characters while preserving whole words and HTML tags + * www.gsdesign.ro/blog/cut-html-string-without-breaking-the-tags + * www.cakephp.org + * + * @param string $text String to truncate. + * @param int $length Length of returned string, including ellipsis. + * @param string $ending Ending to be appended to the trimmed string. + * @param bool $exact If false, $text will not be cut mid-word + * @param bool $considerHtml If true, HTML tags would be handled correctly + * + * @return string Trimmed string. + */ + public static function truncateHtml( + string $text, + int $length = 100, + string $ending = '...', + bool $exact = false, + bool $considerHtml = true + ): string { + $openTags = []; + if ($considerHtml) { + // if the plain text is shorter than the maximum length, return the whole text + if (mb_strlen((string) \preg_replace('/<.*?' . '>/', '', $text)) <= $length) { + return $text; + } + // splits all html-tags to scanable lines + \preg_match_all('/(<.+?' . '>)?([^<>]*)/s', $text, $lines, \PREG_SET_ORDER); + $total_length = mb_strlen($ending); + $openTags = []; + $truncate = ''; + foreach ($lines as $lineMatchings) { + // if there is any html-tag in this line, handle it and add it (uncounted) to the output + if (!empty($lineMatchings[1])) { + // if it's an "empty element" with or without xhtml-conform closing slash + if (\preg_match('/^<(\s*.+?\/\s*|\s*(img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param)(\s.+?)?)>$/is', $lineMatchings[1])) { + // do nothing + // if tag is a closing tag + } elseif (\preg_match('/^<\s*\/(\S+?)\s*>$/s', $lineMatchings[1], $tag_matchings)) { + // delete tag from $openTags list + $pos = \array_search($tag_matchings[1], $openTags, true); + if (false !== $pos) { + unset($openTags[$pos]); + } + // if tag is an opening tag + } elseif (\preg_match('/^<\s*([^\s>!]+).*?' . '>$/s', $lineMatchings[1], $tag_matchings)) { + // add tag to the beginning of $openTags list + \array_unshift($openTags, \mb_strtolower($tag_matchings[1])); + } + // add html-tag to $truncate'd text + $truncate .= $lineMatchings[1]; + } + // calculate the length of the plain text part of the line; handle entities as one character + $content_length = mb_strlen((string) \preg_replace('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', ' ', $lineMatchings[2])); + if ($total_length + $content_length > $length) { + // the number of characters which are left + $left = $length - $total_length; + $entities_length = 0; + // search for html entities + if (\preg_match_all('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', $lineMatchings[2], $entities, \PREG_OFFSET_CAPTURE)) { + // calculate the real length of all entities in the legal range + foreach ($entities[0] as $entity) { + if ($left >= $entity[1] + 1 - $entities_length) { + $left--; + $entities_length += mb_strlen($entity[0]); + } else { + // no more characters left + break; + } + } + } + $truncate .= mb_substr($lineMatchings[2], 0, $left + $entities_length); + // maximum lenght is reached, so get off the loop + break; + } + $truncate .= $lineMatchings[2]; + $total_length += $content_length; + + // if the maximum length is reached, get off the loop + if ($total_length >= $length) { + break; + } + } + } else { + if (mb_strlen($text) <= $length) { + return $text; + } + $truncate = mb_substr($text, 0, $length - mb_strlen($ending)); + } + // if the words shouldn't be cut in the middle... + if (!$exact) { + // ...search the last occurance of a space... + $spacepos = mb_strrpos($truncate, ' '); + if (false !== $spacepos) { + // ...and cut the text in this position + $truncate = mb_substr($truncate, 0, $spacepos); + } + } + // add the defined ending to the text + $truncate .= $ending; + if ($considerHtml) { + // close all unclosed html-tags + foreach ($openTags as $tag) { + $truncate .= ''; + } + } + + return $truncate; + } +} diff --git a/src/Common/TextInterface.php b/src/Common/TextInterface.php new file mode 100644 index 0000000..6529adc --- /dev/null +++ b/src/Common/TextInterface.php @@ -0,0 +1,45 @@ + + */ + +/** + * Contract for the pure text helpers extracted from SysUtility. + * + * Implementations MUST be side-effect free: no request reads, no output, no globals, + * no database. This interface freezes the signature so the class can graduate to XMF + * unchanged (the implementation and this interface move together with its golden test). + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +interface TextInterface +{ + /** + * Truncate a string to a number of characters while preserving whole words and HTML tags. + * + * @param string $text String to truncate. + * @param int $length Length of returned string, including ellipsis. + * @param string $ending Ending to be appended to the trimmed string. + * @param bool $exact If false, $text will not be cut mid-word. + * @param bool $considerHtml If true, HTML tags are handled correctly. + * + * @return string Trimmed string. + */ + public static function truncateHtml( + string $text, + int $length = 100, + string $ending = '...', + bool $exact = false, + bool $considerHtml = true + ): string; +} diff --git a/src/Common/UpdateChecker.php b/src/Common/UpdateChecker.php new file mode 100644 index 0000000..d139ed6 --- /dev/null +++ b/src/Common/UpdateChecker.php @@ -0,0 +1,128 @@ +getModule(); + $moduleDirName = $module instanceof \XoopsModule + ? basename((string)$module->getVar('dirname')) + : basename(\dirname(__DIR__, 2)); + $moduleDirNameUpper = mb_strtoupper($moduleDirName); + $manifestRepository = $module instanceof \XoopsModule ? $module->getInfo('github_repo') : false; + $repository ??= (\is_string($manifestRepository) && '' !== $manifestRepository ? $manifestRepository : null) + ?? 'XoopsModules25x/' . $moduleDirName; + if (1 !== \preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repository)) { + return null; + } + + if ('github' !== $source || !function_exists('curl_init')) { + return null; + } + + // Cache the resolved update result for an hour to avoid hammering the + // GitHub API on every admin page load. The whole computation is + // deterministic given the remote release data, so caching the final + // ?array preserves the existing return contract. + return Cache::remember( + 'mtools_update_' . $moduleDirName, + 3600, + static function () use ($repository, $module, $default): ?array { + $infoReleasesUrl = "https://api.github.com/repos/$repository/releases"; + + // Transient network read — retry twice before giving up. On + // exhausted retries, rescue() emits the cURL error and yields + // false, preserving the original "return null on failure" contract. + $curlReturn = Retry::rescue( + static fn() => Retry::retry(2, static function () use ($infoReleasesUrl) { + $curlHandle = curl_init(); + if (false === $curlHandle) { + throw new \RuntimeException('Unable to initialise cURL'); + } + + curl_setopt($curlHandle, CURLOPT_URL, $infoReleasesUrl); + curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curlHandle, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($curlHandle, CURLOPT_HTTPHEADER, ['User-Agent: XOOPS mtools']); + + $result = curl_exec($curlHandle); + if (false === $result) { + $error = curl_error($curlHandle); + throw new \RuntimeException($error); + } + + return $result; + }), + false, + static function (\Throwable $e): bool { + trigger_error($e->getMessage()); + + return true; + } + ); + + if (false === $curlReturn) { + return null; + } + + if (str_contains((string)$curlReturn, 'Not Found')) { + return null; + } + + $releases = json_decode((string)$curlReturn, false); + if (!is_array($releases) || [] === $releases || !isset($releases[0]->tag_name)) { + return null; + } + + $latestVersionLink = sprintf("https://github.com/$repository/archive/%s.zip", $releases ? reset($releases)->tag_name : $default); + $latestVersion = (string)$releases[0]->tag_name; + $prerelease = (bool)($releases[0]->prerelease ?? false); + $updateLabel = defined('_CO_MTOOLS_NEW_VERSION') + ? constant('_CO_MTOOLS_NEW_VERSION') + : 'New version: '; + + $moduleVersion = $module instanceof \XoopsModule + ? $module->getInfo('version') . '_' . $module->getInfo('module_status') + : '0.0.0'; + + if (new VersionUpdatePolicy()->hasStableUpdate($moduleVersion, $latestVersion, $prerelease)) { + return [ + $updateLabel . $latestVersion, + $latestVersionLink, + ]; + } + + return null; + } + ); + } +} diff --git a/src/Common/VersionChecks.php b/src/Common/VersionChecks.php new file mode 100644 index 0000000..b5c3612 --- /dev/null +++ b/src/Common/VersionChecks.php @@ -0,0 +1,96 @@ + + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + */ +trait VersionChecks +{ + /** + * Verifies XOOPS version meets minimum requirements for this module + * @static + * + * @param null|string $requiredVer + * @return bool true if meets requirements, false if not + */ + public static function checkVerXoops(?\XoopsModule $module = null, $requiredVer = null): bool + { + $module ??= \XoopsModule::getByDirname(self::consumerDirname()); + $moduleDirName = (string)$module->getVar('dirname'); + $errorConstant = '_CO_MTOOLS_ERROR_BAD_XOOPS'; + \xoops_loadLanguage('admin', $moduleDirName); + \xoops_loadLanguage('common', $moduleDirName); + + //check for minimum XOOPS version + $currentVer = mb_substr(\XOOPS_VERSION, 6); // get the numeric part of string + if (null === $requiredVer) { + $requiredVer = '' . $module->getInfo('min_xoops'); //making sure it's a string + } + $success = true; + + if ($module->versionCompare($currentVer, $requiredVer, '<')) { + $success = false; + $module->setErrors(\sprintf(\constant($errorConstant), $requiredVer, $currentVer)); + } + + return $success; + } + + /** + * Verifies PHP version meets minimum requirements for this module + * @static + * @param \XoopsModule|bool|null $module + * + * @return bool true if meets requirements, false if not + */ + public static function checkVerPhp(?\XoopsModule $module = null): bool + { + $module ??= \XoopsModule::getByDirname(self::consumerDirname()); + $moduleDirName = (string)$module->getVar('dirname'); + $errorConstant = '_CO_MTOOLS_ERROR_BAD_PHP'; + \xoops_loadLanguage('admin', $moduleDirName); + \xoops_loadLanguage('common', $moduleDirName); + + // check for minimum PHP version + $success = true; + + $verNum = \PHP_VERSION; + $reqVer = $module->getInfo('min_php'); + + if (false !== $reqVer && '' !== $reqVer && !\is_array($reqVer)) { + if (\version_compare($verNum, $reqVer, '<')) { + $module->setErrors(\sprintf(\constant($errorConstant), $reqVer, $verNum)); + $success = false; + } + } + + return $success; + } + + private static function consumerDirname(): string + { + $calledClass = static::class; + if (preg_match('/^XoopsModules\\\\([^\\\\]+)/', $calledClass, $matches)) { + return strtolower($matches[1]); + } + + return 'mtools'; + } +} diff --git a/src/Constants.php b/src/Constants.php new file mode 100644 index 0000000..10b3c27 --- /dev/null +++ b/src/Constants.php @@ -0,0 +1,14 @@ +title('s'), 'moduletools_object_form', $action, 'post', true); + $form->setExtra('enctype="multipart/form-data"'); + + foreach ($object->vars as $name => $metadata) { + if ( + false === ($metadata['displayOnForm'] ?? true) + || (false === ($metadata['persistent'] ?? true) && !str_starts_with($name, 'close_section_')) + ) { + continue; + } + $element = self::element($object, (string) $name, $metadata); + if (null !== $element) { + $form->addElement($element, (bool) ($metadata['required'] ?? false)); + } + } + + if (null !== $object->handler && is_string($object->handler->keyName ?? null)) { + $key = $object->handler->keyName; + $form->addElement(new \XoopsFormHidden($key, $object->getVar($key, 'e'))); + } + $form->addElement(new \XoopsFormHidden('op', $operation)); + if ($captcha && class_exists('XoopsFormCaptcha')) { + $form->addElement(new \XoopsFormCaptcha(), true); + } + $tray = new \XoopsFormElementTray('', ' '); + $tray->addElement(new \XoopsFormButton('', 'submit', false === $submitCaption ? _SUBMIT : $submitCaption, 'submit')); + if (false !== $cancelAction) { + $cancel = new \XoopsFormButton('', 'cancel', _CANCEL, 'button'); + $cancel->setExtra('onclick="' . htmlspecialchars($cancelAction, ENT_QUOTES | ENT_HTML5) . '"'); + $tray->addElement($cancel); + } + $form->addElement($tray); + + return $form; + } + + /** @param array $metadata */ + private static function element(DynamicObject $object, string $name, array $metadata): ?\XoopsFormElement + { + $caption = (string) ($metadata['form_caption'] ?: $name); + $value = $object->getVar($name, 'e'); + $control = $object->getControl($name); + $controlName = is_array($control) ? (string) ($control['name'] ?? '') : (string) $control; + $type = (int) ($metadata['custom_type'] ?? $metadata['data_type']); + + if ('yesno' === $controlName) { + return new \XoopsFormRadioYN($caption, $name, (string) (int) $value); + } + if ('user' === $controlName) { + return new \XoopsFormSelectUser($caption, $name, false, (int) $value); + } + if ('datetime' === $controlName || in_array($type, [XOBJ_DTYPE_LTIME, XOBJ_DTYPE_STIME], true)) { + return new \XoopsFormDateTime($caption, $name, 15, (int) $value); + } + if (is_array($control) && isset($control['itemHandler'], $control['method'])) { + $module = (string) ($control['module'] ?? ($object->handler->_moduleName ?? '')); + if ('' === $module) { + return null; + } + $helper = \Xmf\Module\Helper::getHelper($module); + $handler = $helper->getHandler((string) $control['itemHandler']); + $options = method_exists($handler, (string) $control['method']) ? $handler->{$control['method']}() : []; + $select = new \XoopsFormSelect($caption, $name, $value); + $select->addOptionArray((array) $options); + return $select; + } + if (in_array($controlName, ['select', 'select_multi', 'radio', 'check'], true)) { + $options = (array) ($control['options'] ?? []); + if ('radio' === $controlName) { + $element = new \XoopsFormRadio($caption, $name, $value); + } elseif ('check' === $controlName) { + $element = new \XoopsFormCheckBox($caption, $name, $value); + } else { + $element = new \XoopsFormSelect($caption, $name, $value, 1, 'select_multi' === $controlName); + } + $element->addOptionArray($options); + return $element; + } + if (XOBJ_DTYPE_FORM_SECTION === $type) { + return new \XoopsFormLabel('', '

    ' . htmlspecialchars($caption, ENT_QUOTES | ENT_HTML5) . '

    '); + } + if (XOBJ_DTYPE_FORM_SECTION_CLOSE === $type) { + return null; + } + if (in_array($type, [XOBJ_DTYPE_TXTAREA], true) || 'textarea' === $controlName) { + return new \XoopsFormDhtmlTextArea($caption, $name, (string) $value, 8, 60); + } + if (in_array($type, [XOBJ_DTYPE_FILE, XOBJ_DTYPE_IMAGE], true) || in_array($controlName, ['file', 'image', 'richfile'], true)) { + $tray = new \XoopsFormElementTray($caption, '
    '); + if ('' !== (string) $value) { + $tray->addElement(new \XoopsFormLabel('', htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5))); + } + $tray->addElement(new \XoopsFormFile('', $name, 0)); + return $tray; + } + + $size = min(80, max(5, (int) ($metadata['size'] ?? 50))); + $maxLength = max($size, (int) ($metadata['maxlength'] ?? 255)); + return new \XoopsFormText($caption, $name, $size, $maxLength, (string) $value); + } +} diff --git a/src/Internal/Confirmation/ConfirmationModel.php b/src/Internal/Confirmation/ConfirmationModel.php new file mode 100644 index 0000000..0e2b9fe --- /dev/null +++ b/src/Internal/Confirmation/ConfirmationModel.php @@ -0,0 +1,19 @@ + $hiddens */ + public function __construct( + public array $hiddens, + public string $action, + public string $object, + public string $title, + public string $label, + ) { + } +} diff --git a/src/Internal/Confirmation/ConfirmationResolver.php b/src/Internal/Confirmation/ConfirmationResolver.php new file mode 100644 index 0000000..c45e98f --- /dev/null +++ b/src/Internal/Confirmation/ConfirmationResolver.php @@ -0,0 +1,44 @@ + $hiddens */ + public function resolve( + array $hiddens, + string $action, + string $requestUri, + string $object, + string $title, + string $label, + ?string $moduleDirName, + ): ConfirmationModel { + if (null !== $moduleDirName && '' !== $moduleDirName) { + $upper = mb_strtoupper($moduleDirName); + if (!defined('_CO_' . $upper . '_DELETE_CONFIRM')) { + define('_CO_' . $upper . '_DELETE_CONFIRM', 'Confirm delete'); + define('_CO_' . $upper . '_DELETE_LABEL', 'Do you really want to delete:'); + } + if ('' === $title) { + $title = (string) constant('_CO_' . $upper . '_DELETE_CONFIRM'); + } + if ('' === $label) { + $label = (string) constant('_CO_' . $upper . '_DELETE_LABEL'); + } + } + $title = '' === $title ? 'Confirm delete' : $title; + $label = '' === $label ? 'Do you really want to delete:' : $label; + + return new ConfirmationModel( + hiddens: $hiddens, + action: '' === $action ? $requestUri : $action, + object: $object, + title: $title, + label: $label, + ); + } +} diff --git a/src/Internal/Installation/InstallationPlan.php b/src/Internal/Installation/InstallationPlan.php new file mode 100644 index 0000000..2bc6114 --- /dev/null +++ b/src/Internal/Installation/InstallationPlan.php @@ -0,0 +1,22 @@ + $uploadFolders + * @param list $blankFileTargets + * @param list $testFolderCopies + */ + public function __construct( + public array $uploadFolders, + public array $blankFileTargets, + public array $testFolderCopies, + public string $blankFileSource, + ) { + } +} diff --git a/src/Internal/PackageLanguage.php b/src/Internal/PackageLanguage.php new file mode 100644 index 0000000..3ad72f0 --- /dev/null +++ b/src/Internal/PackageLanguage.php @@ -0,0 +1,44 @@ +, + * primaryKey:list, + * skipReason:?string + * }> + */ + public function parseSql(string $sql): array + { + $tables = []; + $current = null; + + foreach (preg_split('/\R/', $sql) ?: [] as $line) { + if ($current === null) { + if (preg_match('/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*\(/i', $line, $match)) { + $current = [ + 'name' => $match[1], + 'columns' => [], + 'primaryKey' => [], + 'skipReason' => null, + ]; + } + continue; + } + + if (preg_match('/^\s*\)\s*(?:ENGINE|TYPE|;|$)/i', $line)) { + $this->classifyTable($current); + $tables[$current['name']] = $current; + $current = null; + continue; + } + + if (preg_match('/^\s*PRIMARY\s+KEY\s*\(([^)]+)\)/i', $line, $match)) { + preg_match_all('/`?([A-Za-z_][A-Za-z0-9_]*)`?/', $match[1], $keys); + $current['primaryKey'] = array_values($keys[1]); + continue; + } + + if ( + !preg_match( + '/^\s*`?([A-Za-z_][A-Za-z0-9_]*)`?\s+([A-Za-z]+)(?:\s*\([^)]*\))?\s*(.*?)(?:,\s*)?$/', + $line, + $match, + ) + ) { + continue; + } + + $column = $match[1]; + if (in_array(strtoupper($column), ['PRIMARY', 'UNIQUE', 'KEY', 'FULLTEXT', 'CONSTRAINT', 'FOREIGN'], true)) { + continue; + } + + $sqlType = strtolower($match[2]); + $modifiers = strtoupper($match[3]); + $current['columns'][$column] = [ + 'sqlType' => $sqlType, + 'phpType' => $this->phpType($sqlType), + 'nullable' => !str_contains($modifiers, 'NOT NULL'), + 'autoIncrement' => str_contains($modifiers, 'AUTO_INCREMENT'), + 'dtype' => $this->xoopsType($sqlType), + ]; + } + + if ($current !== null) { + $this->classifyTable($current); + $tables[$current['name']] = $current; + } + + return $tables; + } + + /** + * @param array,skipReason:?string}> $tables + * @return array,skipReason:?string}> + */ + public function eligibleTables(array $tables): array + { + return array_filter($tables, static fn (array $table): bool => $table['skipReason'] === null); + } + + /** + * @param array,skipReason:?string}> $tables + * @param array,skipReason:?string}> $allTables + * @return array{generatedTables:int,skippedTables:int,generatedFiles:int} + */ + public function generate( + string $modulePath, + string $moduleDirname, + string $moduleNamespace, + array $tables, + array $allTables, + ): array { + $modernPath = $modulePath . '/class/Modern'; + $testsPath = $modulePath . '/tests'; + $docsPath = $modulePath . '/docs/architecture'; + foreach ([$modernPath, $testsPath, $docsPath] as $directory) { + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new \RuntimeException('Unable to create bridge directory: ' . $directory); + } + } + + $namespace = 'XoopsModules\\' . $moduleNamespace . '\\Modern'; + $definitions = []; + $generatedFiles = 0; + + if ($tables !== []) { + $this->write($modernPath . '/SqlBridgeRepository.php', $this->renderSqlRepository($namespace)); + ++$generatedFiles; + } + + foreach ($tables as $table) { + $class = $this->className($table['name']); + $properties = $this->propertyMap(array_keys($table['columns'])); + $key = $table['primaryKey'][0]; + $samples = [ + $this->sampleRow($table, true), + $this->sampleRow($table, false), + ]; + + $this->write($modernPath . '/' . $class . 'Entity.php', $this->renderEntity($namespace, $class, $table, $properties)); + $this->write($modernPath . '/' . $class . 'Schema.php', $this->renderSchema($namespace, $class, $table)); + $this->write($modernPath . '/' . $class . 'Repository.php', $this->renderRepository($namespace, $class, $table)); + $this->write($modernPath . '/Bridged' . $class . 'Handler.php', $this->renderHandler($namespace, $moduleDirname, $class, $table['name'], $key)); + $generatedFiles += 4; + + $definitions[] = [ + 'table' => $table['name'], + 'entity' => $namespace . '\\' . $class . 'Entity', + 'schema' => $namespace . '\\' . $class . 'Schema', + 'repository' => $namespace . '\\' . $class . 'Repository', + 'handler' => $namespace . '\\Bridged' . $class . 'Handler', + 'rows' => $samples, + ]; + } + + $this->write($modernPath . '/BridgeManifest.php', $this->renderManifest($namespace, $definitions)); + $this->write($testsPath . '/run-bridge-smoke.php', $this->renderSmoke($moduleDirname, $moduleNamespace)); + $this->write( + $docsPath . '/xoops4-bridge.md', + $this->renderDocumentation($moduleDirname, $tables, $allTables), + ); + $generatedFiles += 3; + + return [ + 'generatedTables' => count($tables), + 'skippedTables' => count($allTables) - count($tables), + 'generatedFiles' => $generatedFiles, + ]; + } + + /** @param array{name:string,columns:array,primaryKey:list,skipReason:?string} $table */ + private function classifyTable(array &$table): void + { + if ($table['primaryKey'] === []) { + $table['skipReason'] = 'missing-primary-key'; + return; + } + if (count($table['primaryKey']) !== 1) { + $table['skipReason'] = 'composite-primary-key'; + return; + } + + $key = $table['primaryKey'][0]; + if (!isset($table['columns'][$key])) { + $table['skipReason'] = 'primary-key-column-not-found'; + return; + } + if ($table['columns'][$key]['phpType'] !== 'int') { + $table['skipReason'] = 'non-integer-primary-key'; + } + } + + private function phpType(string $sqlType): string + { + return match ($sqlType) { + 'tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'year' => 'int', + 'decimal', 'numeric', 'float', 'double', 'real' => 'float', + 'bool', 'boolean' => 'bool', + default => 'string', + }; + } + + private function xoopsType(string $sqlType): string + { + return match ($this->phpType($sqlType)) { + 'int', 'bool' => 'XOBJ_DTYPE_INT', + 'float' => 'XOBJ_DTYPE_OTHER', + default => in_array($sqlType, ['text', 'tinytext', 'mediumtext', 'longtext'], true) + ? 'XOBJ_DTYPE_TXTAREA' + : (in_array($sqlType, ['char', 'varchar', 'enum', 'set'], true) + ? 'XOBJ_DTYPE_TXTBOX' + : 'XOBJ_DTYPE_OTHER'), + }; + } + + private function className(string $table): string + { + return str_replace(' ', '', ucwords(str_replace('_', ' ', strtolower($table)))); + } + + /** @param list $columns @return array */ + private function propertyMap(array $columns): array + { + $result = []; + $used = []; + foreach ($columns as $column) { + $parts = preg_split('/_+/', $column) ?: [$column]; + $property = lcfirst(array_shift($parts) ?? 'field'); + foreach ($parts as $part) { + $property .= ucfirst(strtolower($part)); + } + $property = preg_replace('/[^A-Za-z0-9_]/', '', $property) ?: 'field'; + if (ctype_digit($property[0])) { + $property = 'field' . $property; + } + $base = $property; + $suffix = 2; + while (isset($used[strtolower($property)])) { + $property = $base . $suffix++; + } + $used[strtolower($property)] = true; + $result[$column] = $property; + } + return $result; + } + + /** @param array{name:string,columns:array,primaryKey:list,skipReason:?string} $table @return array */ + private function sampleRow(array $table, bool $useNulls): array + { + $row = []; + $position = 1; + foreach ($table['columns'] as $column => $metadata) { + if ($metadata['nullable'] && $useNulls) { + $row[$column] = null; + } else { + $row[$column] = match ($metadata['phpType']) { + 'int' => $position, + 'float' => $position + 0.25, + 'bool' => true, + default => 'sample-' . $column, + }; + } + ++$position; + } + return $row; + } + + /** @param array{name:string,columns:array,primaryKey:list,skipReason:?string} $table @param array $properties */ + private function renderEntity(string $namespace, string $class, array $table, array $properties): string + { + $parameters = []; + $from = []; + $to = []; + foreach ($table['columns'] as $column => $metadata) { + $property = $properties[$column]; + $type = ($metadata['nullable'] ? '?' : '') . $metadata['phpType']; + $default = $metadata['nullable'] ? 'null' : match ($metadata['phpType']) { + 'int' => '0', + 'float' => '0.0', + 'bool' => 'false', + default => "''", + }; + $parameters[] = ' public ' . $type . ' $' . $property . ' = ' . $default . ','; + $fallback = $metadata['nullable'] ? 'null' : $default; + $value = "\$row['" . $column . "'] ?? " . $fallback; + if ($metadata['nullable']) { + $value = "array_key_exists('" . $column . "', \$row) && \$row['" . $column . "'] !== null" + . " ? (" . $metadata['phpType'] . ") \$row['" . $column . "'] : null"; + } else { + $value = '(' . $metadata['phpType'] . ') (' . $value . ')'; + } + $from[] = ' ' . $property . ': ' . $value . ','; + $to[] = " '" . $column . "' => \$this->" . $property . ','; + } + + $key = $table['primaryKey'][0]; + $keyProperty = $properties[$key]; + + return " \$row */\n" + . " public static function fromArray(array \$row): self\n {\n return new self(\n" + . implode("\n", $from) + . "\n );\n }\n\n /** @return array */\n" + . " public function toArray(): array\n {\n return [\n" + . implode("\n", $to) + . "\n ];\n }\n\n public function isNew(): bool\n {\n" + . " return 0 === \$this->{$keyProperty};\n }\n}\n"; + } + + /** @param array{name:string,columns:array,primaryKey:list,skipReason:?string} $table */ + private function renderSchema(string $namespace, string $class, array $table): string + { + $fields = []; + foreach ($table['columns'] as $column => $metadata) { + $fields[] = " '" . $column . "' => \\" . $metadata['dtype'] . ','; + } + $key = $table['primaryKey'][0]; + return " \$entity->toArray(),\n" + . " );\n }\n}\n"; + } + + /** @param array{name:string,columns:array,primaryKey:list,skipReason:?string} $table */ + private function renderRepository(string $namespace, string $class, array $table): string + { + $columns = "['" . implode("', '", array_keys($table['columns'])) . "']"; + $key = $table['primaryKey'][0]; + return " \$entity->toArray(),\n" + . " );\n }\n}\n"; + } + + private function renderHandler(string $namespace, string $dirname, string $class, string $table, string $key): string + { + return " $columns + * @param class-string $entityClass + * @param \Closure(array):object $fromRow + * @param \Closure(object):array $toRow + */ + protected function __construct( + private readonly \XoopsMySQLDatabase $db, + string $table, + private readonly string $key, + private readonly array $columns, + private readonly string $entityClass, + private readonly \Closure $fromRow, + private readonly \Closure $toRow, + ) { + $this->table = $db->prefix($table); + } + + public function find(int $id): ?object + { + $result = $this->db->query(sprintf( + 'SELECT * FROM `%s` WHERE `%s` = %d LIMIT 1', + $this->table, + $this->key, + $id, + )); + if (!$this->db->isResultSet($result) || !($result instanceof \mysqli_result)) { + return null; + } + $row = $this->db->fetchArray($result); + return is_array($row) ? ($this->fromRow)($row) : null; + } + + public function save(object $entity, ?bool $isNew = null): object + { + $this->assertEntity($entity); + $row = ($this->toRow)($entity); + $id = (int) ($row[$this->key] ?? 0); + if ($isNew ?? ($id === 0)) { + $insert = $id > 0 ? $row : array_diff_key($row, [$this->key => true]); + $columns = array_keys($insert); + $sql = sprintf( + 'INSERT INTO `%s` (`%s`) VALUES (%s)', + $this->table, + implode('`, `', $columns), + implode(', ', array_map($this->renderValue(...), array_values($insert))), + ); + if (!$this->db->exec($sql)) { + throw new \RuntimeException('Insert failed for ' . $this->table . ': ' . $this->db->error()); + } + $row[$this->key] = $id > 0 ? $id : (int) $this->db->getInsertId(); + return ($this->fromRow)($row); + } + + $assignments = []; + foreach ($row as $column => $value) { + if ($column !== $this->key) { + $assignments[] = sprintf('`%s` = %s', $column, $this->renderValue($value)); + } + } + $sql = sprintf( + 'UPDATE `%s` SET %s WHERE `%s` = %d', + $this->table, + implode(', ', $assignments), + $this->key, + $id, + ); + if (!$this->db->exec($sql)) { + throw new \RuntimeException('Update failed for ' . $this->table . ': ' . $this->db->error()); + } + return $entity; + } + + public function delete(object $entity): bool + { + $this->assertEntity($entity); + $row = ($this->toRow)($entity); + return $this->db->exec(sprintf( + 'DELETE FROM `%s` WHERE `%s` = %d', + $this->table, + $this->key, + (int) $row[$this->key], + )); + } + + /** @return list */ + public function findBy(TranslatedCriteria $criteria): array + { + $sql = 'SELECT * FROM `' . $this->table . '`' . $this->whereClause($criteria); + if ($criteria->sort !== '') { + $this->assertColumn($criteria->sort); + $sql .= sprintf(' ORDER BY `%s` %s', $criteria->sort, $criteria->order); + } + if ($criteria->limit > 0) { + $sql .= sprintf(' LIMIT %d OFFSET %d', $criteria->limit, $criteria->start); + } + $result = $this->db->query($sql); + if (!$this->db->isResultSet($result) || !($result instanceof \mysqli_result)) { + return []; + } + $entities = []; + while (false !== ($row = $this->db->fetchArray($result))) { + $entities[] = ($this->fromRow)($row); + } + return $entities; + } + + public function countBy(TranslatedCriteria $criteria): int + { + $result = $this->db->query( + 'SELECT COUNT(*) AS `count` FROM `' . $this->table . '`' . $this->whereClause($criteria), + ); + if (!$this->db->isResultSet($result) || !($result instanceof \mysqli_result)) { + return 0; + } + $row = $this->db->fetchArray($result); + return is_array($row) ? (int) ($row['count'] ?? 0) : 0; + } + + private function whereClause(TranslatedCriteria $criteria): string + { + if ($criteria->isUnfiltered()) { + return ''; + } + $parts = []; + foreach ($criteria->conditions as $condition) { + $column = (string) $condition['column']; + $this->assertColumn($column); + $value = $condition['operator'] === 'IN' + ? $this->renderList((array) $condition['value']) + : $this->renderValue($condition['value']); + $prefix = $parts === [] ? '' : ' ' . $condition['condition'] . ' '; + $clause = sprintf('%s`%s` %s %s', $prefix, $column, $condition['operator'], $value); + if ($condition['operator'] === 'LIKE') { + $clause .= ' ESCAPE ' . $this->db->quote('\\'); + } + $parts[] = $clause; + } + return ' WHERE ' . implode('', $parts); + } + + /** @param list $values */ + private function renderList(array $values): string + { + return $values === [] + ? '(NULL)' + : '(' . implode(', ', array_map($this->renderValue(...), $values)) . ')'; + } + + private function renderValue(mixed $value): string + { + return match (true) { + $value === null => 'NULL', + is_int($value) => (string) $value, + is_float($value) => (string) $value, + is_bool($value) => $value ? '1' : '0', + default => $this->db->quote((string) $value), + }; + } + + private function assertColumn(string $column): void + { + if (!in_array($column, $this->columns, true)) { + throw new \LogicException(sprintf('%s column "%s" is not queryable.', static::class, $column)); + } + } + + private function assertEntity(object $entity): void + { + $entityClass = $this->entityClass; + if (!$entity instanceof $entityClass) { + throw new \InvalidArgumentException(sprintf('%s accepts %s only.', static::class, $entityClass)); + } + } +} +PHP; + return str_replace('{{NAMESPACE}}', $namespace, $template); + } + + /** @param list}> $definitions */ + private function renderManifest(string $namespace, array $definitions): string + { + return "> */\n" + . " public static function definitions(): array\n {\n return " + . var_export($definitions, true) + . ";\n }\n}\n"; + } + + private function renderSmoke(string $dirname, string $moduleNamespace): string + { + $template = <<<'PHP' +toArray() !== $row) { + throw new RuntimeException($definition['table'] . ' entity round-trip failed.'); + } + } + $row = $definition['rows'][0]; + if ($schemaClass::create()->fieldNames() !== array_keys($row)) { + throw new RuntimeException($definition['table'] . ' schema fields differ from the install schema.'); + } + if (!is_subclass_of($repositoryClass, \Xmf\Bridge\EntityRepositoryInterface::class)) { + throw new RuntimeException($repositoryClass . ' does not implement the XMF bridge repository contract.'); + } +} + +fwrite(STDOUT, '{{DIRNAME}} bridge smoke passed (' . count($definitions) . " table(s)).\n"); +PHP; + return str_replace( + ['{{DIRNAME}}', '{{MODULE_NAMESPACE}}'], + [$dirname, $moduleNamespace], + $template, + ); + } + + /** @param array $tables @param array $allTables */ + private function renderDocumentation(string $dirname, array $tables, array $allTables): string + { + if ($allTables === []) { + return implode("\n", [ + '# XOOPS 4 bridge status: ' . $dirname, + '', + 'This module owns no install-schema tables, so it does not require a persistence bridge.', + 'Its remaining transition work is service/controller migration and replacement of compatibility-only helpers', + 'when stable XMF 2 owners exist. It must not claim architecture-native status until that work has executable', + 'behavioral coverage.', + '', + 'The empty `BridgeManifest` and smoke test make the no-persistence decision explicit and reproducible.', + '', + ]); + } + + $lines = [ + '# XOOPS 4 bridge status: ' . $dirname, + '', + 'This module is **XOOPS 4 runtime-compatible, Level 1 (WRAP-ready)**. It is not yet architecture-native.', + 'The generated handlers are opt-in: existing handlers remain the default until forms, permissions, uploads,', + 'notifications, and other side effects have their own executable parity corpus.', + '', + '## Generated single-identity bridges', + '', + ]; + if ($tables === []) { + $lines[] = 'No install-schema table satisfies the current XMF bridge identity contract.'; + } else { + foreach ($tables as $table) { + $lines[] = '- `' . $table['name'] . '` — key `' . $table['primaryKey'][0] . '`.'; + } + } + $lines[] = ''; + $lines[] = '## Explicitly deferred tables'; + $lines[] = ''; + $skipped = array_filter($allTables, static fn (array $table): bool => $table['skipReason'] !== null); + if ($skipped === []) { + $lines[] = 'None.'; + } else { + foreach ($skipped as $table) { + $lines[] = '- `' . $table['name'] . '` — ' . $table['skipReason'] . '.'; + } + } + $lines[] = ''; + $lines[] = 'Run `tests/run-bridge-smoke.php` with `XOOPS4_ROOT` pointing to the XOOPS 4 `htdocs` directory.'; + $lines[] = ''; + return implode("\n", $lines); + } + + private function write(string $path, string $contents): void + { + if (file_put_contents($path, $contents) === false) { + throw new \RuntimeException('Unable to write generated bridge file: ' . $path); + } + } +} diff --git a/src/Internal/Update/VersionUpdatePolicy.php b/src/Internal/Update/VersionUpdatePolicy.php new file mode 100644 index 0000000..18a8bd2 --- /dev/null +++ b/src/Internal/Update/VersionUpdatePolicy.php @@ -0,0 +1,22 @@ +normalize($installedVersion), $this->normalize($latestVersion), '<'); + } + + public function normalize(string $version): string + { + $version = str_replace(' ', '', mb_strtolower($version)); + + return str_contains($version, 'final') ? str_replace(['_', 'final'], '', $version) : $version; + } +} diff --git a/src/Module/ConsumerRuntime.php b/src/Module/ConsumerRuntime.php new file mode 100644 index 0000000..c315734 --- /dev/null +++ b/src/Module/ConsumerRuntime.php @@ -0,0 +1,87 @@ +_mtools_dependency_error()` function whose only per-module variance was + * its name and two version strings. This class centralises that logic so every + * consumer gets identical behavior and messages. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + */ +final class ConsumerRuntime +{ + /** + * Canonical readiness check. Returns '' when mtools is present and new enough, + * otherwise a human-readable message describing what is wrong. + */ + public static function dependencyError( + string $minimumApiVersion = Bootstrap::API_VERSION, + string $minimumModuleVersion = Bootstrap::MIN_MODULE_VERSION + ): string { + $status = Bootstrap::checkRuntime($minimumApiVersion, $minimumModuleVersion); + + return $status['ok'] ? '' : Bootstrap::statusMessage($status); + } + + /** + * Convenience boolean form of {@see self::dependencyError()}. + */ + public static function isReady( + string $minimumApiVersion = Bootstrap::API_VERSION, + string $minimumModuleVersion = Bootstrap::MIN_MODULE_VERSION + ): bool { + return '' === self::dependencyError($minimumApiVersion, $minimumModuleVersion); + } + + /** + * Entry-point guard: on failure, redirect with the message and stop the request. + * + * Use at the top of public/admin entry points (where XOOPS is loaded), e.g. + * `ConsumerRuntime::guard(XOOPS_URL);`. + */ + public static function guard( + string $redirectUrl, + int $delay = 3, + string $minimumApiVersion = Bootstrap::API_VERSION, + string $minimumModuleVersion = Bootstrap::MIN_MODULE_VERSION + ): void { + $error = self::dependencyError($minimumApiVersion, $minimumModuleVersion); + + if ('' !== $error) { + redirect_header($redirectUrl, $delay, $error); + exit; + } + } + + /** + * Install/update hook guard: on failure, record the error on the module and + * report not-ready. Use as `if (!ConsumerRuntime::assertReady($module)) { return false; }`. + */ + public static function assertReady( + \XoopsModule $module, + string $minimumApiVersion = Bootstrap::API_VERSION, + string $minimumModuleVersion = Bootstrap::MIN_MODULE_VERSION + ): bool { + $error = self::dependencyError($minimumApiVersion, $minimumModuleVersion); + + if ('' !== $error) { + $module->setErrors($error); + + return false; + } + + return true; + } +} diff --git a/src/Module/Dependency.php b/src/Module/Dependency.php new file mode 100644 index 0000000..8489ee1 --- /dev/null +++ b/src/Module/Dependency.php @@ -0,0 +1,121 @@ +, module_version: string|null} + */ + public static function checkModule(string $dirname, string $minimumVersion, bool $requireActive = true): array + { + $errors = []; + $dirname = basename($dirname); + + // `mtools` became the Core-bundled ModuleTools capability in XOOPS 2.8. + // Keep the legacy dependency API satisfied without manufacturing a + // module-table row or requiring an active compatibility module. + if (in_array(strtolower($dirname), ['mtools', 'moduletools'], true)) { + $libraryVersion = self::normalizeVersion(Bootstrap::VERSION); + if (version_compare($libraryVersion, self::normalizeVersion($minimumVersion), '<')) { + $errors[] = sprintf( + 'ModuleTools %s is required; library %s is available.', + $minimumVersion, + Bootstrap::VERSION, + ); + } + + return self::status($errors, Bootstrap::VERSION); + } + + if (!defined('XOOPS_ROOT_PATH') || !function_exists('xoops_getHandler')) { + return self::status(['XOOPS is not bootstrapped; load mainfile.php before checking module dependencies.'], null); + } + + $moduleHandler = xoops_getHandler('module'); + $module = $moduleHandler->getByDirname($dirname); + + if (!$module instanceof \XoopsModule) { + return self::status([sprintf('The %s module is not installed.', $dirname)], null); + } + + $moduleVersion = self::normalizeVersion((string)$module->getVar('version')); + + if ($requireActive && (int)$module->getVar('isactive') !== 1) { + $errors[] = sprintf('The %s module is installed but inactive.', $dirname); + } + + if (version_compare($moduleVersion, self::normalizeVersion($minimumVersion), '<')) { + $errors[] = sprintf( + 'The %s module %s is required; module %s is installed.', + $dirname, + $minimumVersion, + $moduleVersion + ); + } + + return self::status($errors, $moduleVersion); + } + + public static function requireModule(string $dirname, string $minimumVersion, bool $requireActive = true): void + { + $status = self::checkModule($dirname, $minimumVersion, $requireActive); + + if (!$status['ok']) { + throw new \RuntimeException(self::statusMessage($status)); + } + } + + public static function statusMessage(array $status): string + { + return implode(' ', $status['errors'] ?? []); + } + + /** + * @param list $errors + * + * @return array{ok: bool, errors: list, module_version: string|null} + */ + private static function status(array $errors, ?string $moduleVersion): array + { + return [ + 'ok' => [] === $errors, + 'errors' => $errors, + 'module_version' => $moduleVersion, + ]; + } + + private static function normalizeVersion(string $version): string + { + $version = trim($version); + + if ('' === $version) { + return '0.0.0'; + } + + if (preg_match('/^\d+$/', $version) === 1 && (int)$version >= 100) { + return number_format(((int)$version) / 100, 2, '.', ''); + } + + return preg_replace('/-(alpha|beta|rc)\d*/i', '', $version) ?? $version; + } +} diff --git a/src/Module/Installer.php b/src/Module/Installer.php new file mode 100644 index 0000000..4c241d3 --- /dev/null +++ b/src/Module/Installer.php @@ -0,0 +1,178 @@ +uploadFolders as $folder) { + SysUtility::createFolder($folder); + } + } + + /** + * Copy the module's own `assets/images/blank.png` into each configured target folder. + */ + public static function copyBlankFiles(Configurator $configurator): void + { + $plan = self::plan($configurator); + if ([] === $plan->blankFileTargets) { + return; + } + foreach ($plan->blankFileTargets as $folder) { + SysUtility::copyFile($plan->blankFileSource, $folder . '/blank.png'); + } + } + + /** + * Recursively copy each configured `[source, destination]` test-data folder pair. + */ + public static function copyTestFolders(Configurator $configurator): void + { + foreach (self::plan($configurator)->testFolderCopies as $pair) { + [$src, $dest] = $pair; + SysUtility::rcopy($src, $dest); + } + } + + /** + * Drop the module's own tables (declared in xoops_version `tables`). Use in pre_install. + */ + public static function dropModuleTables(\XoopsModule $module): void + { + $db = self::runtimeGlobal('xoopsDB'); + foreach ((array)$module->getInfo('tables') as $table) { + $db->exec('DROP TABLE IF EXISTS ' . $db->prefix((string)$table) . ';'); + } + } + + /** + * Delete this module's legacy `.html` rows from the `tplfile` table. + */ + public static function purgeHtmlTemplates(\XoopsModule $module): void + { + $db = self::runtimeGlobal('xoopsDB'); + $sql = 'DELETE FROM ' . $db->prefix('tplfile') . ' WHERE `tpl_module` = ' + . $db->quote((string)$module->getVar('dirname', 'n')) + . " AND `tpl_file` LIKE '%.html%'"; + $db->exec($sql); + } + + /** + * Update cleanup: remove configured legacy `.html` templates, old files and old folders. + */ + public static function removeOldAssets(\XoopsModule $module, Configurator $configurator): void + { + $dirname = (string)$module->getVar('dirname', 'n'); + + // Legacy .html templates (keep index.html). + foreach ($configurator->templateFolders as $folder) { + $templateFolder = self::runtimeGlobal('xoops')->path('modules/' . $dirname . $folder); + if (!\is_dir($templateFolder)) { + continue; + } + $entries = \scandir($templateFolder, \SCANDIR_SORT_NONE); + if (false === $entries) { + continue; + } + foreach (\array_diff($entries, ['.', '..']) as $entry) { + $fileInfo = new \SplFileInfo($templateFolder . $entry); + if ( + 'html' === $fileInfo->getExtension() + && 'index.html' !== $fileInfo->getFilename() + && \is_file($templateFolder . $entry) + ) { + \unlink($templateFolder . $entry); + } + } + } + + // Old files. + foreach ($configurator->oldFiles as $file) { + $tempFile = self::runtimeGlobal('xoops')->path('modules/' . $dirname . $file); + if (\is_file($tempFile)) { + \unlink($tempFile); + } + } + + // Old folders. + \xoops_load('XoopsFile'); + foreach ($configurator->oldFolders as $folder) { + $tempFolder = self::runtimeGlobal('xoops')->path('modules/' . $dirname . $folder); + /** @var \XoopsObjectHandler $folderHandler */ + $folderHandler = \XoopsFile::getHandler('folder', $tempFolder); + $folderHandler->delete($tempFolder); + } + } + + private static function plan(Configurator $configurator): InstallationPlan + { + return new InstallationPlan( + uploadFolders: $configurator->uploadFolders, + blankFileTargets: $configurator->copyBlankFiles, + testFolderCopies: $configurator->copyTestFolders, + blankFileSource: $configurator->baseDir() . '/assets/images/blank.png', + ); + } + + /** @legacy-global-accessor */ + private static function runtimeGlobal(string $name): mixed + { + return $GLOBALS[$name] ?? null; + } +} diff --git a/src/Module/ModuleContext.php b/src/Module/ModuleContext.php new file mode 100644 index 0000000..fe99c38 --- /dev/null +++ b/src/Module/ModuleContext.php @@ -0,0 +1,146 @@ +url('admin/index.php')`); call + * {@see self::defineConstants()} once to keep the legacy `{UP}_*` constants working for + * code that still reads them. + * + * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). + * @since 1.2.0 + * @deprecated since 1.4.0. Use Xoops\Helpers\Service\Path, Url and Config directly; retained through XOOPS 2.8. + */ +final readonly class ModuleContext +{ + private function __construct(public string $dirname) + { + } + + /** + * Build a context from a module dirname (e.g. `ModuleContext::for('quotes')`). + */ + public static function for(string $dirname): self + { + return new self(\basename($dirname)); + } + + /** + * Build a context from a module Helper (reads the module's dirname). + */ + public static function fromHelper(\Xmf\Module\Helper $helper): self + { + $module = $helper->getModule(); + $dirname = ($module instanceof \XoopsModule) ? (string)$module->getVar('dirname', 'n') : ''; + + return new self($dirname); + } + + // ---- filesystem paths (no trailing slash unless noted) ---- + + /** Module root path, optionally with a sub-path: `path('class/Foo.php')`. */ + public function path(string $sub = ''): string + { + return Path::module($this->dirname, $sub); + } + + /** Module admin path. */ + public function adminPath(string $sub = ''): string + { + return Path::module($this->dirname, self::join('admin', $sub)); + } + + /** Module assets/images path. */ + public function imagesPath(string $sub = ''): string + { + return Path::module($this->dirname, self::join('assets/images', $sub)); + } + + /** Module uploads path (under XOOPS uploads), e.g. `uploadPath('category')`. */ + public function uploadPath(string $sub = ''): string + { + return Path::moduleUpload($this->dirname, $sub); + } + + // ---- URLs (no trailing slash unless noted) ---- + + /** Module URL, optionally with a sub-path + query: `url('index.php')`. */ + public function url(string $sub = ''): string + { + return Url::module($this->dirname, $sub); + } + + /** Module admin URL. */ + public function adminUrl(string $sub = ''): string + { + return Url::module($this->dirname, self::join('admin', $sub)); + } + + /** Module assets/images URL. */ + public function imagesUrl(string $sub = ''): string + { + return Url::module($this->dirname, self::join('assets/images', $sub)); + } + + /** Module uploads URL, e.g. `uploadUrl('category/'.rawurlencode($file))`. */ + public function uploadUrl(string $sub = ''): string + { + return Url::moduleUpload($this->dirname, $sub); + } + + // ---- config ---- + + /** Read a module preference: `config('itemsperpage', 10)`. */ + public function config(string $key, mixed $default = null): mixed + { + return Config::get($this->dirname . '.' . $key, $default); + } + + /** + * Define the standard legacy `{UP}_*` constants for this module (idempotent), where + * `{UP}` is the uppercased dirname. Reproduces the conventional constant block so a + * consumer's `include/common.php` can drop ~20 lines for one call. Trailing-slash + * conventions match the historical block (IMAGES/ADMIN/CACHE end with `/`, ROOT/URL/ + * UPLOAD do not). Module-specific constants stay in the consumer. + */ + public function defineConstants(): void + { + $up = \mb_strtoupper($this->dirname); + if (\defined($up . '_URL')) { + return; + } + $path = $this->path(); + $url = $this->url(); + + \define($up . '_DIRNAME', $this->dirname); + \define($up . '_ROOT_PATH', $path); + \define($up . '_PATH', $path); + \define($up . '_URL', $url); + \define($up . '_IMAGES_URL', $url . '/assets/images/'); + \define($up . '_IMAGES_PATH', $path . '/assets/images/'); + \define($up . '_ADMIN_URL', $url . '/admin/'); + \define($up . '_ADMIN_PATH', $path . '/admin/'); + \define($up . '_ADMIN', $url . '/admin/index.php'); + \define($up . '_UPLOAD_URL', $this->uploadUrl()); + \define($up . '_UPLOAD_PATH', $this->uploadPath()); + \define($up . '_CACHE_PATH', $this->uploadPath() . '/'); + } + + private static function join(string $base, string $sub): string + { + return '' === $sub ? $base : $base . '/' . \ltrim($sub, '/'); + } +} diff --git a/src/Module/NamespaceAutoloader.php b/src/Module/NamespaceAutoloader.php new file mode 100644 index 0000000..986f872 --- /dev/null +++ b/src/Module/NamespaceAutoloader.php @@ -0,0 +1,183 @@ + */ + private static array $registrations = []; + + public static function register(string $namespacePrefix, string $classDirectory): void + { + $namespacePrefix = trim($namespacePrefix, '\\') . '\\'; + $classDirectory = rtrim(str_replace('\\', '/', $classDirectory), '/') . '/'; + $registrationKey = strtolower($namespacePrefix . '|' . $classDirectory); + + if (isset(self::$registrations[$registrationKey])) { + return; + } + if (!is_dir($classDirectory)) { + throw new RuntimeException(sprintf('Module class directory does not exist: %s', $classDirectory)); + } + + self::$registrations[$registrationKey] = true; + + spl_autoload_register( + static function (string $class) use ($namespacePrefix, $classDirectory): void { + if (!str_starts_with($class, $namespacePrefix)) { + return; + } + + $relativeClass = ltrim(substr($class, strlen($namespacePrefix)), '\\'); + if ($relativeClass === '') { + return; + } + + $psrFile = $classDirectory . str_replace('\\', '/', $relativeClass) . '.php'; + if (is_file($psrFile)) { + require_once $psrFile; + + return; + } + + /** @var array|null $classMap */ + static $classMap = null; + $classMap ??= self::buildClassMap($classDirectory, $namespacePrefix); + $mappedFile = $classMap[strtolower($class)] ?? null; + if ($mappedFile !== null) { + require_once $mappedFile; + } + }, + ); + } + + /** @return array */ + private static function buildClassMap(string $classDirectory, string $namespacePrefix): array + { + $classMap = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($classDirectory, RecursiveDirectoryIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !$file->isFile() || strtolower($file->getExtension()) !== 'php') { + continue; + } + + $path = $file->getPathname(); + foreach (self::declaredTypes((string) file_get_contents($path)) as $class) { + if (str_starts_with($class . '\\', $namespacePrefix)) { + $classMap[strtolower($class)] = $path; + } + } + } + + return $classMap; + } + + /** @return list */ + private static function declaredTypes(string $source): array + { + $tokens = token_get_all($source); + $namespace = ''; + $types = []; + $typeTokens = [T_CLASS, T_INTERFACE, T_TRAIT]; + if (defined('T_ENUM')) { + $typeTokens[] = T_ENUM; + } + + foreach ($tokens as $index => $token) { + if (!is_array($token)) { + continue; + } + + if ($token[0] === T_NAMESPACE) { + $namespace = self::readNamespace($tokens, $index + 1); + continue; + } + if (!in_array($token[0], $typeTokens, true)) { + continue; + } + if ($token[0] === T_CLASS) { + $previous = self::previousSignificantToken($tokens, $index); + if (is_array($previous) && $previous[0] === T_NEW) { + continue; + } + } + + $name = self::nextTypeName($tokens, $index + 1); + if ($name !== null) { + $types[] = ltrim($namespace . '\\' . $name, '\\'); + } + } + + return $types; + } + + /** @param array $tokens */ + private static function readNamespace(array $tokens, int $start): string + { + $namespace = ''; + $nameTokens = [T_STRING, T_NS_SEPARATOR]; + if (defined('T_NAME_QUALIFIED')) { + $nameTokens[] = T_NAME_QUALIFIED; + } + + for ($index = $start, $count = count($tokens); $index < $count; ++$index) { + $token = $tokens[$index]; + if ($token === ';' || $token === '{') { + break; + } + if (is_array($token) && in_array($token[0], $nameTokens, true)) { + $namespace .= $token[1]; + } + } + + return trim($namespace, '\\'); + } + + /** @param array $tokens */ + private static function nextTypeName(array $tokens, int $start): ?string + { + for ($index = $start, $count = count($tokens); $index < $count; ++$index) { + $token = $tokens[$index]; + if (is_array($token) && $token[0] === T_STRING) { + return $token[1]; + } + if (!is_array($token) && ($token === '{' || $token === '(')) { + return null; + } + } + + return null; + } + + /** @param array $tokens */ + private static function previousSignificantToken(array $tokens, int $index): mixed + { + for ($position = $index - 1; $position >= 0; --$position) { + $token = $tokens[$position]; + if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + return $token; + } + + return null; + } +} diff --git a/src/Object/DynamicObject.php b/src/Object/DynamicObject.php new file mode 100644 index 0000000..3600f88 --- /dev/null +++ b/src/Object/DynamicObject.php @@ -0,0 +1,239 @@ +|string|false> */ + public array $controls = []; + public string $_image_path = ''; + public string $_image_url = ''; + public bool $seoEnabled = false; + public string $titleField = ''; + public string|false $summaryField = false; + + public function initVar($key, $dataType, $value = null, $required = false, $maxlength = null, $options = '', $enumerations = '', $formCaption = '', $formDescription = '', $sortBy = false, $persistent = true, $displayOnForm = true, $multilingual = false): void + { + $coreType = match ((int) $dataType) { + XOBJ_DTYPE_FLOAT, XOBJ_DTYPE_CURRENCY => XOBJ_DTYPE_OTHER, + XOBJ_DTYPE_SIMPLE_ARRAY => XOBJ_DTYPE_ARRAY, + XOBJ_DTYPE_URLLINK, XOBJ_DTYPE_FILE, XOBJ_DTYPE_IMAGE => XOBJ_DTYPE_TXTBOX, + XOBJ_DTYPE_FORM_SECTION, XOBJ_DTYPE_FORM_SECTION_CLOSE => XOBJ_DTYPE_OTHER, + default => $dataType, + }; + parent::initVar($key, $coreType, $value, $required, $maxlength, $options); + $this->vars[$key] += [ + 'custom_type' => (int) $dataType, + 'enumerations' => $enumerations, + 'form_caption' => $formCaption ?: $key, + 'form_dsc' => $formDescription, + 'sortby' => (bool) $sortBy, + 'persistent' => (bool) $persistent, + 'displayOnForm' => (bool) $displayOnForm, + 'multilingual' => (bool) $multilingual, + 'readonly' => false, + ]; + } + + public function initCommonVar(string $name, bool $displayOnForm = true, mixed $default = 'notdefined'): void + { + $value = 'notdefined' === $default ? ('counter' === $name || 'weight' === $name ? 0 : 1) : $default; + $this->initVar($name, XOBJ_DTYPE_INT, $value, false, null, '', '', $name, '', 'weight' === $name, true, $displayOnForm); + if (!in_array($name, ['counter', 'weight'], true)) { + $this->setControl($name, 'yesno'); + } + } + + public function initNonPersistableVar(string $name, int $dataType, mixed $value = null, string $caption = '', string $description = ''): void + { + $this->initVar($name, $dataType, $value, false, null, '', '', $caption, $description, false, false); + } + + public function addFormSection(string $name, mixed $caption = false, bool $hide = false): void + { + $this->initVar($name, XOBJ_DTYPE_FORM_SECTION, $caption, false, null, '', '', (string) $caption, '', false, false, !$hide); + } + + public function closeSection(string $name): void + { + $this->initVar('close_section_' . $name, XOBJ_DTYPE_FORM_SECTION_CLOSE, '', false, null, '', '', '', '', false, false); + } + + public function setControl(string $name, array|string|false $control = []): void + { + $this->controls[$name] = $control; + } + + public function getControl(string $name): array|string|false|null + { + return $this->controls[$name] ?? null; + } + + public function setVarInfo(string $name, string $key, mixed $value): void + { + if (isset($this->vars[$name])) { + $this->vars[$name][$key] = $value; + } + } + + public function getVarInfo(string $name = '', string $key = ''): mixed + { + if ('' === $name) { + return $this->vars; + } + if ('' === $key) { + return $this->vars[$name] ?? null; + } + + return $this->vars[$name][$key] ?? null; + } + + public function setImageDir(string $url, string $path): void + { + $this->_image_url = rtrim($url, '/') . '/'; + $this->_image_path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + public function getUploadDir(bool $path = false): string + { + return $this->getImageDir($path); + } + + public function getImageDir(bool $path = false): string + { + return $path ? $this->_image_path : $this->_image_url; + } + + public function id(): int + { + $key = $this->handler->keyName ?? array_key_first($this->vars); + + return (int) $this->getVar((string) $key, 'e'); + } + + public function title($format = 's'): string + { + $field = $this->titleField ?: (string) ($this->handler->identifierName ?? 'title'); + + return (string) $this->getVar($field, $format); + } + + public function summary(): string + { + return false === $this->summaryField ? '' : (string) $this->getVar($this->summaryField, 's'); + } + + public function getValueFor(string $name, bool $editor = true): mixed + { + if (!isset($this->vars[$name])) { + return null; + } + + $value = $this->getVar($name, 'e'); + $type = (int) ($this->vars[$name]['custom_type'] ?? $this->vars[$name]['data_type']); + if (XOBJ_DTYPE_TXTAREA !== $type) { + return $value; + } + + $myts = \MyTextSanitizer::getInstance(); + return $myts->displayTarea( + (string) $value, + (bool) ($this->getVar('dohtml', 'n') ?? false), + (bool) ($this->getVar('dosmiley', 'n') ?? true), + (bool) ($this->getVar('doxcode', 'n') ?? true), + (bool) ($this->getVar('doimage', 'n') ?? true), + (bool) ($this->getVar('dobr', 'n') ?? true), + ); + } + + public function accessGranted(string $name): bool + { + if (null === $this->handler || !method_exists($this->handler, 'getModuleInfo')) { + return false; + } + $user = self::runtimeUser(); + $groups = is_object($user) ? $user->getGroups() : [XOOPS_GROUP_ANONYMOUS]; + + return ItemPermission::forModule($this->handler->getModuleInfo())->isGranted($name, $this->id(), array_map(intval(...), $groups)); + } + + public function getForm(string $caption = '', string $operation = 'save', string|false $action = false, string|false $submitCaption = false, string|false $cancelAction = false, bool $captcha = false): \XoopsThemeForm + { + return ObjectFormBuilder::build($this, $caption, $operation, $action, $submitCaption, $cancelAction, $captcha); + } + + public function setErrors($error, $prefix = false): void + { + parent::setErrors((false !== $prefix ? $prefix : '') . (string) $error); + } + + public function hasError(): bool + { + return [] !== $this->getErrors(); + } + + public function hideFieldFromForm(string $name): void + { + $this->setVarInfo($name, 'displayOnForm', false); + } + + public function showFieldOnForm(string $name): void + { + $this->setVarInfo($name, 'displayOnForm', true); + } + + public function hideFieldFromSingleView(string $name): void + { + $this->setVarInfo($name, 'displayOnSingleView', false); + } + + public function displayFieldOnSingleView(string $name): void + { + $this->setVarInfo($name, 'displayOnSingleView', true); + } + + public function makeFieldReadOnly(string $name): void + { + $this->setVarInfo($name, 'readonly', true); + } + + public function setFieldForSorting(string $name): void + { + $this->setVarInfo($name, 'sortby', true); + } + + public function setFieldAsRequired(string $name, bool $required = true): void + { + $this->setVarInfo($name, 'required', $required); + } + + /** @legacy-global-accessor */ + private static function runtimeUser(): mixed + { + return $GLOBALS['xoopsUser'] ?? null; + } +} diff --git a/src/Object/SeoObject.php b/src/Object/SeoObject.php new file mode 100644 index 0000000..9c5d7e8 --- /dev/null +++ b/src/Object/SeoObject.php @@ -0,0 +1,26 @@ +seoEnabled = true; + $this->initVar('meta_keywords', XOBJ_DTYPE_TXTAREA, '', false); + $this->initVar('meta_description', XOBJ_DTYPE_TXTAREA, '', false); + $this->initVar('short_url', XOBJ_DTYPE_TXTBOX, '', false, 255); + } + + public function meta_keywords($format = 's'): string + { + return (string) $this->getVar('meta_keywords', $format); + } + + public function meta_description($format = 's'): string + { + return (string) $this->getVar('meta_description', $format); + } +} diff --git a/src/Permission/ItemPermission.php b/src/Permission/ItemPermission.php new file mode 100644 index 0000000..ec300a6 --- /dev/null +++ b/src/Permission/ItemPermission.php @@ -0,0 +1,96 @@ +getVar('mid', 'n'), XoopsPermissionGateway::fromCore()); + } + + /** @return list */ + public function grantedGroups(string $name, int $itemId): array + { + $this->assertArguments($name, $itemId); + + return $this->gateway->groupIds($name, $itemId, $this->moduleId); + } + + /** @param list $groupIds @return list */ + public function grantedItems(string $name, array $groupIds): array + { + $this->assertName($name); + + return $this->gateway->itemIds($name, $this->normalizeIds($groupIds), $this->moduleId); + } + + /** @param list $groupIds */ + public function isGranted(string $name, int $itemId, array $groupIds): bool + { + $this->assertArguments($name, $itemId); + + return $this->gateway->checkRight($name, $itemId, $this->normalizeIds($groupIds), $this->moduleId); + } + + /** @param list $groupIds */ + public function replace(string $name, int $itemId, array $groupIds): bool + { + $this->assertArguments($name, $itemId); + if (!$this->gateway->delete($name, $itemId, $this->moduleId)) { + return false; + } + return array_all($this->normalizeIds($groupIds), fn($groupId) => $this->gateway->add($name, $itemId, $groupId, $this->moduleId)); + } + + public function delete(string $name, int $itemId): bool + { + $this->assertArguments($name, $itemId); + + return $this->gateway->delete($name, $itemId, $this->moduleId); + } + + private function assertArguments(string $name, int $itemId): void + { + $this->assertName($name); + if ($itemId < 0) { + throw new \InvalidArgumentException('Permission item ids cannot be negative.'); + } + } + + private function assertName(string $name): void + { + if (1 !== preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/', $name)) { + throw new \InvalidArgumentException('Invalid permission name.'); + } + } + + /** @param array $ids @return list */ + private function normalizeIds(array $ids): array + { + $normalized = []; + foreach ($ids as $id) { + $id = (int) $id; + if ($id > 0) { + $normalized[$id] = $id; + } + } + + return array_values($normalized); + } +} diff --git a/src/Permission/PermissionGatewayInterface.php b/src/Permission/PermissionGatewayInterface.php new file mode 100644 index 0000000..ca5ec11 --- /dev/null +++ b/src/Permission/PermissionGatewayInterface.php @@ -0,0 +1,21 @@ + */ + public function groupIds(string $name, int $itemId, int $moduleId): array; + + /** @param list $groupIds @return list */ + public function itemIds(string $name, array $groupIds, int $moduleId): array; + + /** @param list $groupIds */ + public function checkRight(string $name, int $itemId, array $groupIds, int $moduleId): bool; + + public function delete(string $name, int $itemId, int $moduleId): bool; + + public function add(string $name, int $itemId, int $groupId, int $moduleId): bool; +} diff --git a/src/Permission/XoopsPermissionGateway.php b/src/Permission/XoopsPermissionGateway.php new file mode 100644 index 0000000..ed2ef09 --- /dev/null +++ b/src/Permission/XoopsPermissionGateway.php @@ -0,0 +1,52 @@ +handler->getGroupIds($name, $itemId, $moduleId))); + } + + public function itemIds(string $name, array $groupIds, int $moduleId): array + { + return array_values(array_map(intval(...), $this->handler->getItemIds($name, $groupIds, $moduleId))); + } + + public function checkRight(string $name, int $itemId, array $groupIds, int $moduleId): bool + { + return (bool) $this->handler->checkRight($name, $itemId, $groupIds, $moduleId); + } + + public function delete(string $name, int $itemId, int $moduleId): bool + { + return (bool) $this->handler->deleteByModule($moduleId, $name, $itemId); + } + + public function add(string $name, int $itemId, int $groupId, int $moduleId): bool + { + return (bool) $this->handler->addRight($name, $itemId, $groupId, $moduleId); + } +} diff --git a/src/Persistence/PersistableHandler.php b/src/Persistence/PersistableHandler.php new file mode 100644 index 0000000..145d60e --- /dev/null +++ b/src/Persistence/PersistableHandler.php @@ -0,0 +1,154 @@ +initialize($itemName, $moduleName, (string) $summaryName); + } + + protected function initialize(string $itemName, string $moduleName, string $summaryName): void + { + $this->_itemname = $itemName; + $this->_moduleName = $moduleName; + $this->summaryName = $summaryName; + $this->_page = $itemName . '.php'; + $this->_modulePath = XOOPS_ROOT_PATH . '/modules/' . $moduleName . '/'; + $this->_moduleUrl = XOOPS_URL . '/modules/' . $moduleName . '/'; + $this->_uploadPath = XOOPS_UPLOAD_PATH . '/' . $moduleName . '/'; + $this->_uploadUrl = XOOPS_UPLOAD_URL . '/' . $moduleName . '/'; + } + + public function create($isNew = true) + { + $class = $this->className; + $object = new $class(); + if (property_exists($object, 'handler')) { + $object->handler = $this; + } + if (method_exists($object, 'setImageDir')) { + $object->setImageDir($this->getImageUrl(), $this->getImagePath()); + } + if ($isNew) { + $object->setNew(); + } + + return $object; + } + + public function setUploaderConfig(string|false $path = false, array|false $mimeTypes = false, int|false $maxFileSize = false, int|false $maxWidth = false, int|false $maxHeight = false): void + { + if (is_string($path)) { + $this->_uploadPath = $path; + } + if (is_array($mimeTypes)) { + $this->_allowedMimeTypes = $mimeTypes; + } + if (false !== $maxFileSize) { + $this->_maxFileSize = $maxFileSize; + } + if (false !== $maxWidth) { + $this->_maxWidth = $maxWidth; + } + if (false !== $maxHeight) { + $this->_maxHeight = $maxHeight; + } + } + + public function getImageUrl(bool $trailingSlash = false): string + { + $url = $this->_uploadUrl . $this->_itemname; + return $trailingSlash ? $url . '/' : $url; + } + + public function getImagePath(bool $trailingSlash = false): string + { + $path = $this->_uploadPath . $this->_itemname; + SysUtility::createFolder($path); + return $trailingSlash ? $path . DIRECTORY_SEPARATOR : $path; + } + + public function addPermission(string $name, string $caption, string|false $description = false): void + { + $this->permissionsArray[] = ['perm_name' => $name, 'caption' => $caption, 'description' => $description]; + } + + public function getPermissions(): array + { + return $this->permissionsArray; + } + + public function getModuleInfo(): object|false + { + return \XoopsModule::getByDirname($this->_moduleName); + } + + public function getModuleConfig(): array + { + $handler = xoops_getHandler('config'); + $module = $this->getModuleInfo(); + return is_object($module) ? $handler->getConfigsByCat(0, (int) $module->getVar('mid', 'n')) : []; + } + + /** + * Compatibility-shaped read used by converted modules. Debug and raw-SQL + * arguments are intentionally ignored; callers must express filtering with Criteria. + */ + public function getObjects2(?\CriteriaElement $criteria = null, bool $idAsKey = false, bool $asObject = true, mixed $sql = null, mixed $debug = null): array + { + return parent::getObjects($criteria, $idAsKey, $asObject); + } + + public function setGrantedObjectsCriteria(\CriteriaCompo $criteria, string $permissionName): bool + { + $module = $this->getModuleInfo(); + if (!is_object($module)) { + return false; + } + $user = self::runtimeUser(); + $groups = is_object($user) ? $user->getGroups() : [XOOPS_GROUP_ANONYMOUS]; + $ids = \Xoops\ModuleTools\Permission\ItemPermission::forModule($module)->grantedItems($permissionName, array_map(intval(...), (array) $groups)); + if ([] === $ids) { + return false; + } + $criteria->add(new \Criteria($this->keyName, array_map(intval(...), $ids), 'IN')); + return true; + } + + /** @legacy-global-accessor */ + private static function runtimeUser(): mixed + { + return $GLOBALS['xoopsUser'] ?? null; + } +} diff --git a/src/Persistence/PersistableHandler2.php b/src/Persistence/PersistableHandler2.php new file mode 100644 index 0000000..1458a4d --- /dev/null +++ b/src/Persistence/PersistableHandler2.php @@ -0,0 +1,17 @@ +, LIKE, IN, IS NULL, etc.) */ + public $operator; + /** @var mixed Value for the operator: scalar for most ops, array or "(a,b)" for IN/NOT IN */ + public $value; + /** @var bool Allow empty string values to render (default false = skip empty) */ + protected $allowEmptyValue = \false; + /** @var bool Allow inner wildcards in LIKE (default false = escape inner % and _) */ + protected $allowInnerWildcards = \false; + /** @var bool Global default for allowing inner wildcards in LIKE across all instances */ + protected static $defaultAllowInnerWildcards = \false; + /** @var bool|null Cached legacy log flag */ + private static $legacyLogEnabled = \null; + /** + * Initialize logging flag once. + * + * Reads the configuration as well as the constant, and the config term is not + * decoration. XOOPS_DB_LEGACY_LOG is defined in mainfile.php, and mainfile.php + * survives an upgrade untouched by design -- so a site upgraded from 2.7.1 has it + * frozen at false, defined before any 2.7.3 code runs, and nothing downstream can + * change it. Setting 'legacy_log' in xoops_data/data/debug.php had no effect there at + * all: the switch the documentation tells you to use was inert on exactly the + * installations most likely to still contain legacy IN lists. + * + * Added to the constant rather than substituted for it, so a hand-edited + * XOOPS_DB_LEGACY_LOG in mainfile.php keeps working. Same rule as + * xoops_isDeveloperRequest(): each term may widen, none may narrow. + * + * function_exists() because this class is reachable from the installer and from unit + * tests, where include/debugconfig.php has not been loaded. + */ + private static function isLegacyLogEnabled(): bool + { + } + /** + * Is debugging on for this site? + * + * Delegates to include/debugconfig.php so there is one predicate, not a copy that + * drifts. NOT xoops_isDeveloperRequest(): this decides whether to compute a backtrace + * and raise a deprecation notice, both of which end up in a log. A cron run or a CLI + * script has no logged-in user, and withholding a deprecation notice from it because + * nobody is watching would be the wrong question answered confidently. + * + * Falls back to the bare constant when debugconfig.php is absent -- the installer and + * the unit tests reach this class without it. + */ + private static function isDebugEnabled(): bool + { + } + /** + * Check if a legacy IN value is a safe, parenthesized list of quoted literals. + * Supports both single and double quotes: ("foo","bar") or ('foo','bar') + */ + private static function isSafeLegacyInList(string $raw): bool + { + } + /** + * Predicate for an empty IN / NOT IN list. + * + * MySQL rejects the literal `IN ()` with a syntax error, so an empty set + * renders as a constant instead: nothing is a member of the empty set, and + * everything is outside it. + * + * @param string $op uppercased operator, 'IN' or 'NOT IN' + * @return string constant predicate + */ + private static function emptyInPredicate(string $op): string + { + } + /** + * Render a validated legacy (preformatted string) IN list. + * + * @param string $clause rendered column reference + * @param string $op uppercased operator, 'IN' or 'NOT IN' + * @param string $legacy validated legacy list, e.g. "(1,2,3)" + * @return string SQL fragment + */ + private static function renderLegacyInList(string $clause, string $op, string $legacy): string + { + } + /** + * Set the global default for allowing inner wildcards in LIKE patterns. + * Useful during migrations of legacy modules that intentionally use inner wildcards. + * + * @param bool $on + * @return void + */ + public static function setDefaultAllowInnerWildcards(bool $on = \true): void + { + } + /** + * Opt-in per instance for intentional inner wildcards in LIKE patterns. + * Default remains secure (inner %/_ escaped). + * + * @param bool $on + * @return $this + */ + public function allowInnerWildcards(bool $on = \true): self + { + } + /** + * Constructor + * + * @param string $column + * @param mixed $value + * @param string $operator + * @param string|null $prefix + * @param string|null $function sprintf format string, e.g. 'LOWER(%s)' + * @param bool $allowEmptyValue + */ + public function __construct($column, $value = '', $operator = '=', $prefix = '', $function = '', $allowEmptyValue = \false) + { + } + /** + * Backtick-quote a column reference for safe use as an SQL identifier. + * + * Expression columns (those containing a parenthesis) are developer-supplied + * and returned untouched — the optional $function wrapper handles those. A + * plain or dotted identifier is split on '.', each segment backtick-quoted + * with any embedded backtick doubled, closing the identifier-breakout hole. + * + * @param string $col column name or expression + * @return string quoted identifier, or the original expression + */ + private function quoteColumn($col) + { + } + /** + * Render the SQL fragment (no leading WHERE) + * + * @param \XoopsDatabase|null $db Database connection + * @return string SQL fragment + * @throws \RuntimeException if database connection is not available + */ + public function render(?\XoopsDatabase $db = \null) + { + } + /** + * Render with leading WHERE clause + * + * @param \XoopsDatabase|null $db Database connection + * @return string SQL WHERE clause or empty string + */ + public function renderWhere(?\XoopsDatabase $db = \null) + { + } + /** + * Generate an LDAP filter from criteria + * + * @return string LDAP filter + */ + public function renderLdap() + { + } +} +} + +namespace { +/** + * Collection of multiple {@link CriteriaElement}s + * + */ +class CriteriaCompo extends \CriteriaElement +{ + /** + * The elements of the collection + * + * @var array Array of {@link CriteriaElement} objects + */ + public $criteriaElements = []; + /** + * Conditions + * + * @var array + */ + public $conditions = []; + /** + * Constructor + * + * @param CriteriaElement|null $ele + * @param string $condition + */ + public function __construct(?\CriteriaElement $ele = \null, $condition = 'AND') + { + } + /** + * Add an element + * + * @param CriteriaElement|object $criteriaElement + * @param string $condition + * @return object reference to this collection + */ + public function &add(\CriteriaElement $criteriaElement, $condition = 'AND') + { + } + /** + * Make the criteria into a query string + * + * @return string + */ + public function render(?\XoopsDatabase $db = \null): string + { + } + /** + * Make the criteria into a SQL "WHERE" clause + * @param \XoopsDatabase|null $db + * @return string + */ + public function renderWhere(?\XoopsDatabase $db = \null): string + { + } + /** + * Generate an LDAP filter from criteria + * + * @return string + * @author Nathan Dial ndial@trillion21.com + */ + public function renderLdap() + { + } +} +} + +namespace { +/** + * A criteria (grammar?) for a database query. + * + * Abstract base class should never be instantiated directly. + * + * @abstract + */ +class CriteriaElement +{ + /** + * Sort order + * + * @var string + */ + public $order = 'ASC'; + /** + * + * @var string + */ + public $sort = ''; + /** + * Number of records to retrieve + * + * @var int + */ + public $limit = 0; + /** + * Offset of first record + * + * @var int + */ + public $start = 0; + /** + * + * @var string + */ + public $groupby = ''; + /** + * Constructor + */ + public function __construct() + { + } + /** + * Render the criteria element + * @param \XoopsDatabase|null $db + * @return string + */ + public function render(?\XoopsDatabase $db = \null) + { + } + /** + * + * @param string $sort + */ + public function setSort($sort) + { + } + /** + * + * @return string + */ + public function getSort() + { + } + /** + * + * @param string $order + */ + public function setOrder($order) + { + } + /** + * + * @return string + */ + public function getOrder() + { + } + /** + * + * @param int $limit + */ + public function setLimit($limit = 0) + { + } + /** + * + * @return int + */ + public function getLimit() + { + } + /** + * + * @param int $start + */ + public function setStart($start = 0) + { + } + /** + * + * @return int + */ + public function getStart() + { + } + /** + * + * @param string $group + */ + public function setGroupBy($group) + { + } + /** + * + * @return string + */ + public function getGroupby() + { + } + /** + * Validate an ORDER BY / GROUP BY column-list fragment. + * + * Accepts a comma-separated list of (optionally backticked or dotted) + * column references, each with an optional ASC/DESC direction. Any other + * construct — parentheses, quotes, comment markers, semicolons, operators — + * indicates an injection attempt and collapses the fragment to an empty + * string so the caller omits the clause entirely (fail closed). + * + * @param string $clause raw sort/groupby fragment + * @return string validated fragment, or '' when it is not a safe column list + */ + protected function sanitizeOrderClause($clause) + { + } +} +} + +namespace { +/** + * Class to "clean up" text for various uses + * + * Singleton + * + * @package kernel + * @subpackage core + * @author Kazumi Ono + * @author Taiwen Jiang + * @author Goghs Cheng + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + */ +class MyTextSanitizer +{ + /** + * + * @var array + */ + public $smileys = []; + /** + */ + public $censorConf; + /** + * + * @var string holding reference to text + */ + public $text = ''; + public $patterns = []; + public $replacements = []; + public $callbackPatterns = []; + public $callbacks = []; + public $path_basic; + public $path_config; + public $path_plugin; + public $config; + /** + * Constructor of this class + * + * Gets allowed html tags from admin config settings + *
    should not be allowed since nl2br will be used + * when storing data. + * + * @access private + */ + public function __construct() + { + } + /** + * Enter description here... + * + * @param string $name + * @return array|string + */ + public function loadConfig($name = \null) + { + } + /** + * Enter description here... + * + * @param array $config_default + * @param array $config_custom + * @return mixed + */ + public function mergeConfig($config_default, $config_custom) + { + } + /** + * Access the only instance of this class + * + * @return MyTextSanitizer + */ + public static function getInstance() + { + } + /** + * Get the smileys + * + * @param bool $isAll TRUE for all smileys, FALSE for smileys with display = 1 + * + * @return array + */ + public function getSmileys($isAll = \true) + { + } + /** + * Replace emoticons in the message with smiley images + * + * @param string $message + * @return string + */ + public function smiley($message) + { + } + /** + * Callback to process email address match + * + * @param array $match array of matched elements + * + * @return string + */ + protected function makeClickableCallbackEmailAddress($match) + { + } + public function makeClickable($text) + { + } + /** + * MyTextSanitizer::truncate() + * + * @param mixed $text + * @return mixed|string + */ + public function truncate($text) + { + } + /** + * Replace XoopsCodes with their equivalent HTML formatting + * + * @param string $text + * @param bool|int $allowimage Allow images in the text? + * On FALSE, uses links to the images. + * @return string + */ + public function &xoopsCodeDecode(&$text, $allowimage = 1) + { + } + /** + * Convert quote tags + * + * @param string $text + * @return string + */ + public function quoteConv($text) + { + } + /** + * A quick, non-authoritative scheme denylist. + * + * This is a coarse denylist, NOT a complete XSS filter: it neutralises a + * handful of dangerous URL schemes but does not cover HTML event handlers, + * CSS expressions, or every obfuscation. It is disabled by default and must + * not be relied on as the primary XSS control — use XoopsPurifier (HTMLPurifier) + * for untrusted HTML. + * + * @param string $text + * @return mixed + */ + public function filterXss($text) + { + } + /** + * Convert linebreaks to
    tags + * + * @param string $text + * @return string + */ + public function nl2Br($text) + { + } + /** + * Add slashes to the text if magic_quotes_gpc is turned off. + * + * @param string $text + * @return string + * @deprecated + */ + public function addSlashes($text) + { + } + /** + * Convert special characters to HTML entities + * + * $text accepts any scalar, not just string. Callers legitimately pass ints, floats and + * bools -- ids, counts, years, timestamps, flags -- and the body has always cast to string + * on the first line, so the value was never used as anything but a string. A bare "string" + * declaration made that cast unreachable for such callers: strict_types is decided by the + * CALLING file, so any module with declare(strict_types=1) got an uncaught + * TypeError: Argument #1 ($text) must be of type string, int given + * instead of the intended coercion. That took the xoops.org front page down from + * wgtimelines/blocks/timelines.php, and this was the only method in this class carrying + * such a declaration -- every sibling sanitizer is untyped. + * + * The union covers exactly what (string) handles predictably: bool casts to '1'/'', + * null to ''. Arrays and objects are still rejected, which is the check actually worth + * keeping. + * + * @param string|int|float|bool|null $text string being converted + * @param int|null $quote_style + * @param string|null $charset character set used in conversion + * @param bool $double_encode + * @return string + * @since 2.7.3 widened from string + */ + public function htmlSpecialChars(string|int|float|bool|null $text, ?int $quote_style = \null, ?string $charset = \null, $double_encode = \true) + { + } + /** + * Reverses {@link htmlSpecialChars()} + * + * @param string $text + * @return string + */ + public function undoHtmlSpecialChars($text) + { + } + /** + * Filters textarea form data in DB for display + * + * @param string $text + * @param bool|int $html allow html? + * @param bool|int $smiley allow smileys? + * @param bool|int $xcode allow xoopscode? + * @param bool|int $image allow inline images? + * @param bool|int $br convert linebreaks? + * @return string + */ + public function &displayTarea($text, $html = 0, $smiley = 1, $xcode = 1, $image = 1, $br = 1) + { + } + /** + * Filters textarea form data submitted for preview + * + * @param string $text + * @param bool|int $html allow html? + * @param bool|int $smiley allow smileys? + * @param bool|int $xcode allow xoopscode? + * @param bool|int $image allow inline images? + * @param bool|int $br convert linebreaks? + * @return string + */ + public function &previewTarea($text, $html = 0, $smiley = 1, $xcode = 1, $image = 1, $br = 1) + { + } + /** + * Replaces banned words in a string with their replacements + * + * @param string $text + * @return string + */ + public function &censorString(&$text) + { + } + /** + * MyTextSanitizer::codePreConv() + * + * @param mixed $text + * @param mixed $xcode + * @return mixed + */ + public function codePreConv($text, $xcode = 1) + { + } + /** + * @param $match + * + * @return string + */ + public function codeConvCallback($match) + { + } + /** + * Drop the stray
    that sits immediately against a block-level [code] / [quote] box. + * + * Both boxes end up carrying a trailing `
    `, by two different routes. `quoteConv()` + * runs inside {@see self::xoopsCodeDecode()}, BEFORE `nl2Br()`, so the newline the author + * typed after `[/quote]` is still a newline when the quote is wrapped and only becomes a + * `
    ` afterwards. `codeConv()` runs AFTER `nl2Br()`, so for a code block that newline + * is already a `
    ` by the time the wrapping happens. Either way the rendered result + * is a `
    ` sitting immediately after the closing ``. + * + * The div is block-level and supplies its own vertical separation, so that `
    ` renders + * as an extra empty line — one after a code block, and after a quote as well. + * + * Only the TRAILING break is removed, and only one of them. The two sides are not + * symmetrical: a `
    ` that PRECEDES a block element merely terminates the previous + * line and shows no gap of its own, so an author's blank line before `[code]` is carried + * by two breaks and removing one visibly closes the gap. After the block there is no such + * line to terminate, so the break renders as a whole empty line on top of the div's own + * spacing. An author who deliberately left a blank line after the block still keeps one. + * + * The pattern is anchored at BOTH ends of the generated box. It has to open on the + * `xoopsCode` / `xoopsQuote` wrapper, because those classes are the only thing that marks + * the div as ours: matching the closing side alone would also strike a break the author + * deliberately wrote after their own `
    …
    `, which is reachable + * whenever HTML is permitted. It closes on the inner tag rather than a bare `` for + * the same reason from the other side. `pre` is listed alongside `code` because + * {@see MytsSyntaxhighlight::load()} falls back to a plain `
    …
    ` when its + * `highlight` option is off, so the box then closes ``. + * + * The span between the two anchors stays lazy so that nested quotes still work: the inner + * `` is not followed by a break, so the match keeps growing out to the + * outer one that is. + * + * Anything that is not a string is handed back untouched. `codeConv()` above is documented + * `@return mixed` and the whole display chain is untyped, so a non-string can reach here; + * coercing it would turn an array into the literal "Array" rather than leave the value for + * the next filter to deal with as it always has. + * + * @param mixed $text rendered text + * @return mixed the text with the break removed, or $text unchanged if it is not a string + */ + protected function trimBlockBreaks($text) + { + } + /** + * MyTextSanitizer::codeConv() + * + * @param mixed $text + * @param mixed $xcode + * @return mixed + */ + public function codeConv($text, $xcode = 1) + { + } + /** + * MyTextSanitizer::executeExtensions() + * + * @return bool + */ + public function executeExtensions() + { + } + /** + * MyTextSanitizer::loadExtension() + * + * @param mixed $name + * @return MyTextSanitizerExtension|false + */ + public function loadExtension($name) + { + } + /** + * MyTextSanitizer::executeExtension() + * + * @param mixed $name + * @return mixed + */ + public function executeExtension($name) + { + } + /** + * Filter out possible malicious text + * kses project at SF could be a good solution to check + * + * @param string $text text to filter + * @param bool $force force filtering + * @return string filtered text + */ + public function textFilter($text, $force = \false) + { + } + // #################### Deprecated Methods ###################### + /** + * if magic_quotes_gpc is on, strip back slashes + * + * @param string $text + * @return string + * @deprecated as of XOOPS 2.5.11 and will be removed in next XOOPS version + * + * This remains here until we officially drop support for PHP 5.3 in next release + */ + public function stripSlashesGPC($text) + { + } + /** + * MyTextSanitizer::codeSanitizer() + * + * @param mixed $str + * @param mixed $image + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function codeSanitizer($str, $image = 1) + { + } + /** + * MyTextSanitizer::sanitizeForDisplay() + * + * @param mixed $text + * @param integer $allowhtml + * @param integer $smiley + * @param mixed $bbcode + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function sanitizeForDisplay($text, $allowhtml = 0, $smiley = 1, $bbcode = 1) + { + } + /** + * MyTextSanitizer::sanitizeForPreview() + * + * @param mixed $text + * @param integer $allowhtml + * @param integer $smiley + * @param mixed $bbcode + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function sanitizeForPreview($text, $allowhtml = 0, $smiley = 1, $bbcode = 1) + { + } + /** + * MyTextSanitizer::makeTboxData4Save() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function makeTboxData4Save($text) + { + } + /** + * MyTextSanitizer::makeTboxData4Show() + * + * @param mixed $text + * @param mixed $smiley + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function makeTboxData4Show($text, $smiley = 0) + { + } + /** + * MyTextSanitizer::makeTboxData4Edit() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function makeTboxData4Edit($text) + { + } + /** + * MyTextSanitizer::makeTboxData4Preview() + * + * @param mixed $text + * @param mixed $smiley + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function makeTboxData4Preview($text, $smiley = 0) + { + } + /** + * MyTextSanitizer::makeTboxData4PreviewInForm() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function makeTboxData4PreviewInForm($text) + { + } + /** + * MyTextSanitizer::makeTareaData4Save() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function makeTareaData4Save($text) + { + } + /** + * MyTextSanitizer::makeTareaData4Show() + * + * @param mixed $text + * @param integer $html + * @param integer $smiley + * @param mixed $xcode + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function &makeTareaData4Show($text, $html = 1, $smiley = 1, $xcode = 1) + { + } + /** + * MyTextSanitizer::makeTareaData4Edit() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function makeTareaData4Edit($text) + { + } + /** + * MyTextSanitizer::makeTareaData4Preview() + * + * @param mixed $text + * @param integer $html + * @param integer $smiley + * @param mixed $xcode + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function &makeTareaData4Preview($text, $html = 1, $smiley = 1, $xcode = 1) + { + } + /** + * MyTextSanitizer::makeTareaData4PreviewInForm() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function makeTareaData4PreviewInForm($text) + { + } + /** + * MyTextSanitizer::makeTareaData4InsideQuotes() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function makeTareaData4InsideQuotes($text) + { + } + /** + * MyTextSanitizer::oopsStripSlashesGPC() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function oopsStripSlashesGPC($text) + { + } + /** + * MyTextSanitizer::oopsStripSlashesRT() + * + * @param mixed $text + * @return mixed|string + * @deprecated will be removed in next XOOPS version + */ + public function oopsStripSlashesRT($text) + { + } + /** + * MyTextSanitizer::oopsAddSlashes() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function oopsAddSlashes($text) + { + } + /** + * MyTextSanitizer::oopsHtmlSpecialChars() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function oopsHtmlSpecialChars($text) + { + } + /** + * MyTextSanitizer::oopsNl2Br() + * + * @param mixed $text + * @return string + * @deprecated will be removed in next XOOPS version + */ + public function oopsNl2Br($text) + { + } +} +} + +namespace { +/** + * A block + * + * @author Kazumi Ono + * + * @package kernel + * + * @todo reconcile the two XoopsBlock classes. + * @internal This handler appears to only be loaded by system/class/group.php + * @internal The other, in class/xoopsblock.php is loaded all over + */ +class XoopsBlock extends \XoopsObject +{ + /** @var string Logger category for block warnings */ + private const BLOCK_WARNING = 'Block Warning'; + //PHP 8.2 Dynamic properties deprecated + public $bid; + public $mid; + public $func_num; + public $options; + public $name; + //public $position; + public $title; + public $content; + public $side; + public $weight; + public $visible; + public $block_type; + public $c_type; + public $isactive; + public $dirname; + public $func_file; + public $show_func; + public $edit_func; + public $template; + public $bcachetime; + public $last_modified; + /** + * constructor + * + * @param mixed $id + **/ + public function __construct($id = \null) + { + } + /** + * Returns Class Base Variable bid + * @param string $format + * @return mixed + */ + public function id($format = 'n') + { + } + /** + * Returns Class Base Variable bid + * @param string $format + * @return mixed + */ + public function bid($format = '') + { + } + /** + * Returns Class Base Variable mid + * @param string $format + * @return mixed + */ + public function mid($format = '') + { + } + /** + * Returns Class Base Variable func_num + * @param string $format + * @return mixed + */ + public function func_num($format = '') + { + } + /** + * Returns Class Base Variable avatar_id + * @param string $format + * @return mixed + */ + public function options($format = '') + { + } + /** + * Returns Class Base Variable name + * @param string $format + * @return mixed + */ + public function name($format = '') + { + } + /** + * Returns Class Base Variable title + * @param string $format + * @return mixed + */ + public function title($format = '') + { + } + /** @return mixed block name exactly as stored, without token resolution */ + public function rawName(?string $format = \null) + { + } + /** @return mixed block title exactly as stored, without token resolution */ + public function rawTitle(?string $format = \null) + { + } + /** + * Resolve a stored constant key while retaining literal custom labels. + */ + private function localizedLabel(string $field, string $format = ''): string + { + } + /** + * Returns Class Base Variable content + * @param string $format + * @return mixed + */ + public function content($format = '') + { + } + /** + * Returns Class Base Variable side + * @param string $format + * @return mixed + */ + public function side($format = '') + { + } + /** + * Returns Class Base Variable weight + * @param string $format + * @return mixed + */ + public function weight($format = '') + { + } + /** + * Returns Class Base Variable visible + * @param string $format + * @return mixed + */ + public function visible($format = '') + { + } + /** + * Returns Class Base Variable block_type + * + * Valid block_type values are: + * S - generated by system module + * M - generated by a non-system module + * C - Custom block + * D - cloned system/module block + * E - cloned custom block, DON'T use it + * + * @param string $format + * + * @return mixed + */ + public function block_type($format = '') + { + } + /** + * Returns Class Base Variable c_type + * @param string $format + * @return mixed + */ + public function c_type($format = '') + { + } + /** + * Returns Class Base Variable isactive + * @param string $format + * @return mixed + */ + public function isactive($format = '') + { + } + /** + * Returns Class Base Variable dirname + * @param string $format + * @return mixed + */ + public function dirname($format = '') + { + } + /** + * Returns Class Base Variable func_file + * @param string $format + * @return mixed + */ + public function func_file($format = '') + { + } + /** + * Returns Class Base Variable show_func + * @param string $format + * @return mixed + */ + public function show_func($format = '') + { + } + /** + * Returns Class Base Variable edit_func + * @param string $format + * @return mixed + */ + public function edit_func($format = '') + { + } + /** + * Returns Class Base Variable template + * @param string $format + * @return mixed + */ + public function template($format = '') + { + } + /** + * Returns Class Base Variable avatar_id + * @param string $format + * @return mixed + */ + public function bcachetime($format = '') + { + } + /** + * Returns Class Base Variable last_modified + * @param string $format + * @return mixed + */ + public function last_modified($format = '') + { + } + /** + * return the content of the block for output + * + * @param string $format + * @param string $c_type type of content + * Valid values for the type of content: + * H : custom HTML block + * P : custom PHP block + * S : use text sanitizer (smilies enabled) + * T : use text sanitizer (smilies disabled) + * @return string content for output + */ + public function getContent($format = 's', $c_type = 'T') + { + } + /** + * Execute a PHP block — file-based only (eval removed in 2.7.0). + * + * The content field should contain: filename.php|function_name + * The file must exist in XOOPS_ROOT_PATH/custom_blocks/ + * The function must exist after including the file and return an HTML string. + * + * Legacy eval()-based blocks are no longer executed; a deprecation warning + * is logged and empty content is returned. + * + * @return string rendered block content + * + * Note: no return type declaration to preserve BC for subclasses that override this method. + */ + protected function executePhpBlock() + { + } + /** + * Get the directory path for custom block files. + * + * Subclasses or tests may override this to point to a different directory. + * + * @return string absolute path to the custom_blocks directory (no trailing slash) + */ + protected static function getCustomBlocksDir(): string + { + } + /** + * Execute a file-based PHP block (no eval). + * + * Validates the file path, includes it, verifies function origin via + * ReflectionFunction, and captures both returned and echoed output. + * + * @param string $funcFile filename in custom_blocks/ + * @param string $showFunc function name to call + * + * @return string rendered block content + */ + private function executeFileBasedBlock(string $funcFile, string $showFunc): string + { + } + /** + * Execute a previously verified file-based block (fast path, skips validation). + * + * @param string $realPath resolved file path (already included) + * @param string $showFunc verified function name + * + * @return string rendered block content + */ + private function executeVerifiedBlock(string $realPath, string $showFunc): string + { + } + /** + * Legacy eval()-based PHP block execution — permanently removed. + * + * eval() of database-stored PHP has been removed in XOOPS 2.7.0 for security. + * Blocks using the file-based format (filename.php|function_name) still work. + * Convert legacy P-type blocks to file-based, H (HTML), or S/T (sanitized text) type. + * + * @param string $raw raw PHP code from the block content field (retained for call-site compatibility) + * + * @return string always empty + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + private function executeLegacyBlock(string $raw): string + { + } + /** + * Log a block warning via XoopsLogger extra entries. + * + * @param string $message warning message + * + * @return void + */ + private function logBlockWarning(string $message): void + { + } + /** + * (HTML-) form for setting the options of the block + * + * @return string HTML for the form, FALSE if not defined for this block + */ + public function getOptions() + { + } + /** + * @return bool + */ + public function isCustom() + { + } + /** + * These methods are for compatibility with the pre 2.5.11 class/xoopsblock.php + * class/xoopsblock.php defined its own XoopsBlock class, making it impossible + * to use with anything that used the handler provided in kernel/block.php + * + * In addition to the actual data, the old XoopsBlock contained what should be + * considered handler logic. + * + * It appears that class/xoopsblock.php came first, but a conversion to the kernel + * handler was never completed. + * + * These methods should all be considered deprecated. + */ + /** + * Load $id + * + * @param int $id + * + * @deprecated + */ + public function load($id) + { + } + /** + * Store Block Data to Database + * + * @return int|false id of inserted block, or false on failure + * + * @deprecated + */ + public function store() + { + } + /** + * Delete an ID from the database + * + * @return bool + * + * @deprecated + */ + public function delete() + { + } + /** + * Build Block + * + * @return mixed + * + * @deprecated + */ + public function buildBlock() + { + } + /* + * Aligns the content of a block + * If position is 0, content in DB is positioned + * before the original content + * If position is 1, content in DB is positioned + * after the original content + */ + /** + * @param $position + * @param string $content + * @param string $contentdb + * + * @return string + * + * @deprecated + */ + public function buildContent($position, $content = '', $contentdb = '') + { + } + /** + * Enter description here... + * + * @param string $originaltitle + * @param string $newtitle + * @return string title + * + * @deprecated + */ + public function buildTitle($originaltitle, $newtitle = '') + { + } + /** + * get all the blocks that match the supplied parameters + * @param int|array $groupid groupid (can be an array) + * @param bool $asobject + * @param string|null $side 0: sideblock - left + * 1: sideblock - right + * 2: sideblock - left and right + * 3: centerblock - left + * 4: centerblock - right + * 5: centerblock - center + * 6: centerblock - left, right, center + * @param $visible 0: not visible 1: visible + * @param string $orderby order of the blocks + * @param int $isactive + * @returns array of block objects + * + * @deprecated + */ + public static function getAllBlocksByGroup($groupid, $asobject = \true, $side = \null, $visible = \null, $orderby = 'b.weight,b.bid', $isactive = 1) + { + } + /** + * XoopsBlock::getAllBlocks() + * + * @param string $rettype + * @param mixed $side + * @param mixed $visible + * @param string $orderby + * @param integer $isactive + * @return array + * + * @deprecated + */ + public function getAllBlocks($rettype = 'object', $side = \null, $visible = \null, $orderby = 'side,weight,bid', $isactive = 1) + { + } + /** + * XoopsBlock::getByModule() + * + * @param mixed $moduleid + * @param mixed $asobject + * @return array + */ + public static function getByModule($moduleid, $asobject = \true) + { + } + /** + * XoopsBlock::getAllByGroupModule() + * + * @param mixed $groupid + * @param integer $module_id + * @param mixed $toponlyblock + * @param mixed $visible + * @param string $orderby + * @param integer $isactive + * @return array + * + * @deprecated (This also appears, dead, in XoopsBlockHandler) + */ + public function getAllByGroupModule($groupid, $module_id = 0, $toponlyblock = \false, $visible = \null, $orderby = 'b.weight, m.block_id', $isactive = 1) + { + } + /** + * XoopsBlock::getNonGroupedBlocks() + * + * @param integer $module_id + * @param mixed $toponlyblock + * @param mixed $visible + * @param string $orderby + * @param integer $isactive + * @return array + * + * @deprecated + */ + public function getNonGroupedBlocks($module_id = 0, $toponlyblock = \false, $visible = \null, $orderby = 'b.weight, m.block_id', $isactive = 1) + { + } + /** + * XoopsBlock::countSimilarBlocks() + * + * @param mixed $moduleId + * @param mixed $funcNum + * @param mixed $showFunc + * @return int + * + * @deprecated + */ + public function countSimilarBlocks($moduleId, $funcNum, $showFunc = \null) + { + } +} +} + +namespace { +/** + * XOOPS block handler class. (Singleton) + * + * This class is responsible for providing data access mechanisms to the data source + * of XOOPS block class objects. + * + * @author Kazumi Ono + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + * @package kernel + * @subpackage block + * + * @todo Why is this not a XoopsPersistableObjectHandler? + */ +class XoopsBlockHandler extends \XoopsObjectHandler +{ + /** + * create a new block + * + * @see XoopsBlock + * @param bool $isNew is the new block new?? + * @return XoopsBlock XoopsBlock reference to the new block + **/ + public function create($isNew = \true) + { + } + /** + * retrieve a specific {@link XoopsBlock} + * + * @see XoopsBlock + * @param int $id bid of the block to retrieve + * @return XoopsBlock reference to the block + **/ + public function get($id) + { + } + /** + * write a new block into the database + * + * @param XoopsObject|XoopsBlock $block a XoopsBlock object + * + * @return bool true on success, otherwise false + */ + public function insert(\XoopsObject $block) + { + } + /** + * delete a block from the database + * + * @param XoopsObject|XoopsBlock $block a XoopsBlock object + * + * @return bool true on success, otherwise false + */ + public function delete(\XoopsObject $block) + { + } + /** + * retrieve array of {@link XoopsBlock}s meeting certain conditions + * @param CriteriaElement|CriteriaCompo $criteria {@link CriteriaElement} with conditions for the blocks + * @param bool $id_as_key should the blocks' bid be the key for the returned array? + * @return array {@link XoopsBlock}s matching the conditions + **/ + public function getObjects(?\CriteriaElement $criteria = \null, $id_as_key = \false) + { + } + /** + * get a list of blocks matching certain conditions + * + * @param CriteriaElement $criteria conditions to match + * @return array array of blocks matching the conditions + **/ + public function getList(?\CriteriaElement $criteria = \null) + { + } + ##################### Deprecated Methods ###################### + /* These are not deprecated, they are dead and should be removed */ + /** + * @deprecated + * @param $moduleid + * @param bool $asobject + * @param bool $id_as_key + * @return bool + */ + public function getByModule($moduleid, $asobject = \true, $id_as_key = \false) + { + } + /** + * @param mixed $groupid + * @param int $module_id + * @param bool $toponlyblock + * @param mixed $visible + * @param string $orderby + * @param int $isactive + * + * @return bool + * @deprecated + */ + public function getAllByGroupModule($groupid, $module_id = 0, $toponlyblock = \false, $visible = \null, $orderby = 'i.weight,i.instanceid', $isactive = 1) + { + } + /** + * @param $groupid + * @param string $orderby + * + * @return bool + * @deprecated + */ + public function getAdminBlocks($groupid, $orderby = 'i.weight,i.instanceid') + { + } + /** + * @return bool + * @deprecated + */ + public function assignBlocks() + { + } +} +} + +namespace { +/** + * XOOPS configuration handling class. + * This class acts as an interface for handling general configurations of XOOPS + * and its modules. + * + * @author Kazumi Ono + * @todo Tests that need to be made: + * - error handling + * @access public + */ +class XoopsConfigHandler +{ + /** + * holds reference to config item handler(DAO) class + * + * @var object + * @access private + */ + public $_cHandler; + /** + * holds reference to config option handler(DAO) class + * + * @var object + * @access private + */ + public $_oHandler; + /** + * holds an array of cached references to config value arrays, + * indexed on module id and category id + * + * @var array + * @access private + */ + public $_cachedConfigs = []; + /** + * Constructor + * + * @param XoopsDatabase $db reference to database object + */ + public function __construct(\XoopsDatabase $db) + { + } + /** + * Create a config + * + * @see XoopsConfigItem + * @return XoopsConfigItem reference to the new {@link XoopsConfigItem} + */ + public function createConfig() + { + } + /** + * Get a config + * + * @param int $id ID of the config + * @param bool $withoptions load the config's options now? + * @return object reference to the {@link XoopsConfig} + */ + public function getConfig($id, $withoptions = \false) + { + } + /** + * insert a new config in the database + * + * @param XoopsConfigItem $config reference to the {@link XoopsConfigItem} + * + * @return bool + */ + public function insertConfig(\XoopsConfigItem $config) + { + } + /** + * Delete a config from the database + * + * @param XoopsConfigItem $config reference to a {@link XoopsConfigItem} + * + * @return bool + */ + public function deleteConfig(\XoopsConfigItem $config) + { + } + /** + * get one or more Configs + * + * @param CriteriaElement $criteria {@link CriteriaElement} + * @param bool $id_as_key Use the configs' ID as keys? + * @param bool $with_options get the options now? + * + * @return array Array of {@link XoopsConfigItem} objects + */ + public function getConfigs(?\CriteriaElement $criteria = \null, $id_as_key = \false, $with_options = \false) + { + } + /** + * Count some configs + * + * @param CriteriaElement $criteria {@link CriteriaElement} + * + * @return int + */ + public function getConfigCount(?\CriteriaElement $criteria = \null) + { + } + /** + * Get configs from a certain category + * + * @param int $category ID of a category + * @param int $module ID of a module + * + * @return array array of {@link XoopsConfig}s + */ + public function &getConfigsByCat($category, $module = 0) + { + } + /** + * Make a new {@link XoopsConfigOption} + * + * @return XoopsConfigOption {@link XoopsConfigOption} + */ + public function createConfigOption() + { + } + /** + * Get a {@link XoopsConfigOption} + * + * @param int $id ID of the config option + * + * @return XoopsConfigOption {@link XoopsConfigOption} + */ + public function getConfigOption($id) + { + } + /** + * Get one or more {@link XoopsConfigOption}s + * + * @param CriteriaElement $criteria {@link CriteriaElement} + * @param bool $id_as_key Use IDs as keys in the array? + * + * @return array Array of {@link XoopsConfigOption}s + */ + public function getConfigOptions(?\CriteriaElement $criteria = \null, $id_as_key = \false) + { + } + /** + * Count some {@link XoopsConfigOption}s + * + * @param CriteriaElement $criteria {@link CriteriaElement} + * + * @return int Count of {@link XoopsConfigOption}s matching $criteria + */ + public function getConfigOptionsCount(?\CriteriaElement $criteria = \null) + { + } + /** + * Get a list of configs + * + * @param int $conf_modid ID of the modules + * @param int $conf_catid ID of the category + * + * @return array Associative array of name=>value pairs. + */ + public function getConfigList($conf_modid, $conf_catid = 0) + { + } + /**#@+ + * @deprecated + * @param $criteria + * @return bool + */ + public function deleteConfigOption(&$criteria) + { + } +} +} + +namespace { +/** + * Abstract base class for Database access classes + * + * @abstract + * @author Kazumi Ono + * @package kernel + * @subpackage database + */ +abstract class XoopsDatabase +{ + /** + * Prefix for tables in the database + * + * @var string + */ + public $prefix = ''; + /** + * reference to a {@link XoopsLogger} object + * + * @see XoopsLogger + * @var object XoopsLogger + */ + public $logger; + /** + * If statements that modify the database are selected + * + * @var boolean + */ + public $allowWebChanges = \false; + /** + * XoopsDatabase constructor. + */ + public function __construct() + { + } + /** + * assign a {@link XoopsLogger} object to the database + * + * @see XoopsLogger + * @param XoopsLogger $logger reference to a {@link XoopsLogger} object + */ + public function setLogger(\XoopsLogger $logger) + { + } + /** + * set the prefix for tables in the database + * + * @param string $value table prefix + */ + public function setPrefix($value) + { + } + /** + * attach the prefix.'_' to a given tablename + * + * if tablename is empty, only prefix will be returned + * + * @param string $tablename tablename + * @return string prefixed tablename, just prefix if tablename is empty + */ + public function prefix($tablename = '') + { + } + /** + * Test the passed result to determine if it is a valid result set + * + * @param mixed $result value to test + * + * @return bool true if $result is a database result set, otherwise false + */ + abstract public function isResultSet($result); + /** + * Return a human-readable description of the last DB error. + * Subclasses must override to provide engine-specific details. + * + * @return string Error message or empty string if no error. + */ + abstract public function error(); + /** + * Return an engine-specific error code for the last DB error. + * Subclasses must override to provide engine-specific details. + * + * @return int Error code (e.g., MySQL errno) or 0 if no error. + */ + abstract public function errno(); + /** + * Executes a SELECT-like statement and returns a result handle. + * If $limit is null, no LIMIT/OFFSET is applied. + * If $limit is not null and $start is null, $start defaults to 0. + * + * @param string $sql + * @param int|null $limit + * @param int|null $start + * @return mixed driver result/resource|false + */ + abstract public function query(string $sql, ?int $limit = \null, ?int $start = \null); + /** + * Executes a mutating statement (INSERT/UPDATE/DELETE/DDL). + * New code should prefer exec(). + */ + abstract public function exec(string $sql); + /** + * Legacy alias for writes; kept for BC. + * @deprecated + */ + public function queryF(string $sql) + { + } + /** + * Canonical quoting API — returns a single-quoted SQL literal. + * MUST include surrounding single quotes and perform driver-appropriate escaping. + * + * @param string $str Raw, unescaped string + * @return string Quoted SQL literal, e.g. 'O''Reilly' for input "O'Reilly" + */ + abstract public function quote(string $str); + /** + Legacy alias. Returns a single-quoted SQL literal. + * @deprecated Use quote() + */ + public function quoteString(string $str) + { + } + /** + * Helper: normalize pagination semantics for drivers. + * - limit=null => [null, null] (no pagination) + * - limit>=0 => [limit, max(0, start??0)] + */ + final protected function normalizeLimitStart(?int $limit, ?int $start): array + { + } +} +} + +namespace { +/** + * XoopsDatabaseFactory + * + * @package Kernel + * @author Kazumi Ono + * @access public + */ +class XoopsDatabaseFactory +{ + /** + * XoopsDatabaseFactory constructor. + */ + public function __construct() + { + } + /** + * Get a reference to the only instance of database class and connects to DB + * + * if the class has not been instantiated yet, this will also take + * care of that + * + * @static + * @staticvar XoopsDatabase The only instance of database class + * @return XoopsDatabase Reference to the only instance of database class + */ + public static function getDatabaseConnection() + { + } + /** + * Gets a reference to the only instance of database class. Currently, + * only being used within the installer. + * + * @static + * @staticvar object The only instance of database class + * @return object Reference to the only instance of database class + */ + public static function getDatabase() + { + } +} +} + +namespace { +/** + * XoopsFile + * + * @package + * @author Taiwen Jiang + * @access public + */ +class XoopsFile +{ + /** + * XoopsFile::__construct() + */ + public function __construct() + { + } + /** + * XoopsFile::getInstance() + * + * @return + */ + public function getInstance() + { + } + /** + * XoopsFile::load() + * + * @param string $name + * + * @return bool + */ + public static function load($name = 'file') + { + } + /** + * XoopsFile::getHandler() + * + * @param string $name + * @param mixed $path + * @param mixed $create + * @param mixed $mode + * @return + */ + public static function getHandler($name = 'file', $path = \false, $create = \false, $mode = \null) + { + } +} +} + +namespace { +/** + * Abstract base class for forms + * + * @author Kazumi Ono + * @author Taiwen Jiang + * @package kernel + * @subpackage form + * @access public + */ +class XoopsForm +{ + /** + * *#@+ + * + * @access private + */ + /** + * "action" attribute for the html form + * + * @var string + */ + public $_action; + /** + * "method" attribute for the form. + * + * @var string + */ + public $_method; + /** + * "name" attribute of the form + * + * @var string + */ + public $_name; + /** + * title for the form + * + * @var string + */ + public $_title; + /** + * summary for the form (WGAC2 Requirement) + * + * @var string + */ + public $_summary = ''; + /** + * array of {@link XoopsFormElement} objects + * + * @var array + */ + public $_elements = []; + /** + * HTML classes for the
    tag + * + * @var array + */ + public $_class = []; + /** + * extra information for the tag + * + * @var array + */ + public $_extra = []; + /** + * required elements + * + * @var array + */ + public $_required = []; + /** + * additional serialized object checksum (ERM Analysis - Requirement) + * @deprecated + * @access private + */ + public $_objid = 'da39a3ee5e6b4b0d3255bfef95601890afd80709'; + /** + * *#@- + */ + /** + * constructor + * + * @param string $title title of the form + * @param string $name "name" attribute for the tag + * @param string $action "action" attribute for the tag + * @param string $method "method" attribute for the tag + * @param bool $addtoken whether to add a security token to the form + * @param string $summary + */ + public function __construct($title, $name, $action, $method = 'post', $addtoken = \true, $summary = '') + { + } + /** + * PHP 4 style constructor compatibility shim + * @deprecated all callers should be using parent::__construct() + */ + public function XoopsForm() + { + } + /** + * *#@+ + * retrieves object serialization/identification id (sha1 used) + * + * each object has serialization
    + * - legal requirement of enterprise relational management (ERM) + * + * @deprecated + * @access public + * @param mixed $object The object or value to serialize + * @param string $hashinfo Hashing algorithm to use (default 'sha1') + * @return string The serialization ID + */ + public function getObjectID($object, $hashinfo = 'sha1') + { + } + /** + * @param mixed $value + * @param mixed $key + * @param string $ret + * @param string $hashinfo + * + * @return string + */ + public function getArrayID($value, $key, $ret, $hashinfo = 'sha1') + { + } + /** + * return the summary of the form + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getSummary($encode = \false) + { + } + /** + * return the title of the form + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getTitle($encode = \false) + { + } + /** + * get the "name" attribute for the tag + * + * Deprecated, to be refactored + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getName($encode = \true) + { + } + /** + * get the "action" attribute for the tag + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getAction($encode = \true) + { + } + /** + * get the "method" attribute for the tag + * + * @return string + */ + public function getMethod() + { + } + /** + * Add an element to the form + * + * @param string|XoopsFormElement $formElement reference to a {@link XoopsFormElement} + * @param bool $required is this a "required" element? + * + */ + public function addElement($formElement, $required = \false) + { + } + /** + * get an array of forms elements + * + * @param bool $recurse get elements recursively? + * + * @return XoopsFormElement[] array of {@link XoopsFormElement}s + */ + public function &getElements($recurse = \false) + { + } + /** + * get an array of "name" attributes of form elements + * + * @return array array of form element names + */ + public function getElementNames() + { + } + /** + * get a reference to a {@link XoopsFormElement} object by its "name" + * + * @param string $name "name" attribute assigned to a {@link XoopsFormElement} + * @return object reference to a {@link XoopsFormElement}, false if not found + */ + public function &getElementByName($name) + { + } + /** + * Sets the "value" attribute of a form element + * + * @param string $name the "name" attribute of a form element + * @param string $value the "value" attribute of a form element + */ + public function setElementValue($name, $value) + { + } + /** + * Sets the "value" attribute of form elements in a batch + * + * @param array $values array of name/value pairs to be assigned to form elements + */ + public function setElementValues($values) + { + } + /** + * Gets the "value" attribute of a form element + * + * @param string $name the "name" attribute of a form element + * @param bool $encode To sanitizer the text? + * @return string the "value" attribute assigned to a form element, null if not set + */ + public function getElementValue($name, $encode = \false) + { + } + /** + * gets the "value" attribute of all form elements + * + * @param bool $encode To sanitizer the text? + * @return array array of name/value pairs assigned to form elements + */ + public function getElementValues($encode = \false) + { + } + /** + * set the "class" attribute for the tag + * + * @param string $class + */ + public function setClass($class) + { + } + /** + * set the extra attributes for the tag + * + * @param string $extra extra attributes for the tag + */ + public function setExtra($extra) + { + } + /** + * set the summary tag for the tag + * + * @param string $summary + */ + public function setSummary($summary) + { + } + /** + * get the "class" attribute for the tag + * + * @return string "class" attribute value + */ + public function getClass() + { + } + /** + * get the extra attributes for the tag + * + * @return string + */ + public function &getExtra() + { + } + /** + * make an element "required" + * + * @param XoopsFormElement $formElement reference to a {@link XoopsFormElement} + */ + public function setRequired(\XoopsFormElement $formElement) + { + } + /** + * get an array of "required" form elements + * + * @return array array of {@link XoopsFormElement}s + */ + public function &getRequired() + { + } + /** + * insert a break in the form + * + * This method is abstract. It must be overwritten in the child classes. + * + * @param string $extra extra information for the break + * @abstract + */ + public function insertBreak($extra = \null) + { + } + /** + * returns renderered form + * + * This method is abstract. It must be overwritten in the child classes. + * + * @abstract + */ + public function render() + { + } + /** + * displays rendered form + */ + public function display() + { + } + /** + * Renders the Javascript function needed for client-side for validation + * + * Form elements that have been declared "required" and not set will prevent the form from being + * submitted. Additionally, each element class may provide its own "renderValidationJS" method + * that is supposed to return custom validation code for the element. + * + * The element validation code can assume that the JS "myform" variable points to the form, and must + * execute return false if validation fails. + * + * A basic element validation method may contain something like this: + * + * function renderValidationJS() { + * $name = $this->getName(); + * return "if (myform.{$name}.value != 'valid') { " . + * "myform.{$name}.focus(); window.alert( '$name is invalid' ); return false;" . + * " }"; + * } + * + * + * @param boolean $withtags Include the < javascript > tags in the returned string + * + * @return string + */ + public function renderValidationJS($withtags = \true) + { + } + /** + * assign to smarty form template instead of displaying directly + * + * @param XoopsTpl $tpl reference to a {@link Smarty} object object + * @see Smarty + */ + public function assign(\XoopsTpl $tpl) + { + } +} +} + +namespace { +/** + * + * + * @package kernel + * @subpackage form + * + * @author Kazumi Ono + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + */ +/** + * A button + * + * @author Kazumi Ono + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + * + * @package kernel + * @subpackage form + */ +class XoopsFormButton extends \XoopsFormElement +{ + /** + * Value + * @var string + * @access private + */ + public $_value; + /** + * Type of the button. This could be either "button", "submit", or "reset" + * @var string + * @access private + */ + public $_type; + /** + * Constructor + * + * @param string $caption Caption + * @param string $name + * @param string $value + * @param string $type Type of the button. Potential values: "button", "submit", or "reset" + */ + public function __construct($caption, $name, $value = '', $type = 'button') + { + } + /** + * Get the initial value + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getValue($encode = \false) + { + } + /** + * Set the initial value + * + * @param $value + * + * @return string + */ + public function setValue($value) + { + } + /** + * Get the type + * + * @return string + */ + public function getType() + { + } + /** + * prepare HTML for output + * + * @return string + */ + public function render() + { + } +} +} + +namespace { +/** + * XoopsFormButtonTray + * + * @author John Neill + * @package kernel + * @subpackage form + * @access public + */ +class XoopsFormButtonTray extends \XoopsFormElement +{ + /** + * Value + * + * @var string + * @access private + */ + public $_value; + /** + * Type of the button. This could be either "button", "submit", or "reset" + * + * @var string + * @access private + */ + public $_type; + public $_showDelete; + /** + * Constructor + * + * @param mixed $name + * @param string $value + * @param string $type + * @param string $onclick + * @param bool $showDelete + */ + public function __construct($name, $value = '', $type = '', $onclick = '', $showDelete = \false) + { + } + /** + * XoopsFormButtonTray::getValue() + * + * @return string + */ + public function getValue() + { + } + /** + * XoopsFormButtonTray::setValue() + * + * @param mixed $value + * + * @return void + */ + public function setValue($value) + { + } + /** + * XoopsFormButtonTray::getType() + * + * @return string + */ + public function getType() + { + } + /** + * XoopsFormButtonTray::render() + * + * @return string|void + */ + public function render() + { + } +} +} + +namespace { +/** + * Usage of XoopsFormCaptcha + * + * For form creation: + * Add form element where proper: $xoopsform->addElement(new XoopsFormCaptcha($caption, $name, $skipmember, $configs)); + * + * For verification: + * + * xoops_load('xoopscaptcha'); + * $xoopsCaptcha = XoopsCaptcha::getInstance(); + * if (! $xoopsCaptcha->verify() ) { + * echo $xoopsCaptcha->getMessage(); + * ... + * } + * + */ +/** + * Xoops Form Captcha + * + * @author Taiwen Jiang + * @package kernel + * @subpackage form + */ +class XoopsFormCaptcha extends \XoopsFormElement +{ + public $captchaHandler; + /** + * Constructor + * @param string $caption Caption of the form element, default value is defined in captcha/language/ + * @param string $name Name for the input box + * @param boolean $skipmember Skip CAPTCHA check for members deprecated + * @param array $configs deprecated + */ + public function __construct($caption = '', $name = 'xoopscaptcha', $skipmember = '', $configs = []) + { + } + /** + * @param $name + * @param $val + * + * @return mixed + */ + public function setConfig($name, $val) + { + } + /** + * @return mixed + */ + public function render() + { + } + /** + * @return mixed + */ + public function renderValidationJS() + { + } +} +} + +namespace { +/** + * Class XoopsFormCheckBox + */ +class XoopsFormCheckBox extends \XoopsFormElement +{ + /** + * Available options + * + * @var array + * @access private + */ + public $_options = []; + /** + * pre-selected values in array + * + * @var array + * @access private + */ + public $_value = []; + /** + * HTML to separate the elements + * + * @var string + * @access private + */ + public $_delimeter; + /** + * Columns per line for rendering + * Leave unset (null) to put all options in one line + * Set to 1 to put each option on its own line + * Any other positive integer 'n' to put 'n' options on each line + * + * @var int + * @access public + */ + public $columns; + /** + * Constructor + * + * @param string $caption + * @param string $name + * @param mixed $value Either one value as a string or an array of them. + * @param string $delimeter + */ + public function __construct($caption, $name, $value = \null, $delimeter = ' ') + { + } + /** + * Get the "value" + * + * @param bool $encode To sanitizer the text? + * @return array + */ + public function getValue($encode = \false) + { + } + /** + * Set the "value" + * + * @param array $value + * + */ + public function setValue($value) + { + } + /** + * Add an option + * + * @param string $value + * @param string $name + */ + public function addOption($value, $name = '') + { + } + /** + * Add multiple Options at once + * + * @param array $options Associative array of value->name pairs + */ + public function addOptionArray($options) + { + } + /** + * Get an array with all the options + * + * @param bool|int $encode To sanitizer the text? potential values: 0 - skip; 1 - only for value; 2 - for both value and name + * @return array Associative array of value->name pairs + */ + public function getOptions($encode = \false) + { + } + /** + * Get the delimiter of this group + * + * @param bool $encode To sanitizer the text? + * @return string The delimiter + */ + public function getDelimeter($encode = \false) + { + } + /** + * prepare HTML for output + * + * @return string + */ + public function render() + { + } + /** + * Render custom javascript validation code + * + * @seealso XoopsForm::renderValidationJS + */ + public function renderValidationJS() + { + } +} +} + +namespace { +/** + * Formal contract for form elements that group other form elements. + * + * Implemented by {@link XoopsFormElementTray} and {@link XoopsFormTabTray}. A + * container keeps its children part of the owning form, so the form's required + * handling and generated client-side validation reach every nested element. + * + * The legacy discriminator for "is this a container?" was the {@link + * XoopsFormElement::isContainer()} method. This interface formalises that role + * so callers can detect containers with `instanceof` while the return types of + * getRequired()/getElements() stay PHPDoc-only — they are long-standing public + * signatures used across the module ecosystem and MUST NOT gain native return + * types, which would be a backward-compatibility break. + * + * @category XoopsForm + * @package XoopsFormContainerInterface + * @author XOOPS Development Team + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +interface XoopsFormContainerInterface +{ + /** + * Is this element a container of other elements? + * + * @return bool + */ + public function isContainer(); + /** + * Get the required child elements (by reference, so the owning form can + * merge them into its own required list). + * + * @return XoopsFormElement[] array of {@link XoopsFormElement}s + */ + public function &getRequired(); + /** + * Get the child elements. With $recurse, nested containers are flattened so + * the owning form sees every leaf element. + * + * @param bool $recurse flatten nested containers? + * + * @return XoopsFormElement[] array of {@link XoopsFormElement}s + */ + public function &getElements($recurse = \false); +} +} + +namespace { +/** + * Date and time selection field + * + * @author Kazumi Ono + * @package kernel + * @subpackage form + */ +class XoopsFormDateTime extends \XoopsFormElementTray +{ + public const SHOW_BOTH = 1; + public const SHOW_DATE = 0; + public const SHOW_TIME = 2; + /** + * XoopsFormDateTime::XoopsFormDateTime() + * + * @param mixed $caption form field caption + * @param mixed $name form variable name + * @param integer $size size of date select + * @param integer $value unix timestamp, defaults to now + * @param mixed $showtime control display of date and time elements + * SHOW_BOTH, true - show both date and time selectors + * SHOW_DATE, false - only show date selector + * SHOW_TIME - only show time selector + */ + public function __construct($caption, $name, $size = 15, $value = 0, $showtime = \true) + { + } +} +} + +namespace { +/** + * A textarea with xoopsish formatting and smilie buttons + * + */ +class XoopsFormDhtmlTextArea extends \XoopsFormTextArea +{ + /** + * Extended HTML editor + * + *

    If an extended HTML editor is set, the renderer will be replaced by the specified editor, usually a visual or WYSIWYG editor.

    + * + *
      Developer and user guide: + *
      • For run-time settings per call + *
      • To use an editor pre-configured by {@link XoopsEditor}, e.g. 'tinymce7': $options['editor'] = 'tinymce7';
      • + *
      • To use a custom editor, e.g. 'MyEditor' class located in "/modules/myeditor/myeditor.php": $options['editor'] = array('MyEditor', XOOPS_ROOT_PATH . "/modules/myeditor/myeditor.php");
      • + *
    • + *
      • For pre-configured settings, which will force to use an editor if no specific editor is set for call + *
        • Set up custom configs: in XOOPS_VAR_PATH . '/configs/xoopsconfig.php' set an editor as default, e.g. + *
        • a pre-configured editor 'tinymce7': return array('editor' => 'tinymce7');
        • + *
        • a custom editor 'MyEditor' class located in "/modules/myeditor/myeditor.php": return array('editor' => array('MyEditor', XOOPS_ROOT_PATH . "/modules/myeditor/myeditor.php");
        • + *
      • + *
      • To disable the default editor, in XOOPS_VAR_PATH . '/configs/xoopsconfig.php': return array();
      • + *
      • To disable the default editor for a specific call: $options['editor'] = 'dhtmltextarea';
      • + *
    • + *
    + */ + public $htmlEditor = []; + /** + * Hidden text + * + * @var string + * @access private + */ + public $_hiddenText; + public $skipPreview = \false; + public $doHtml = \false; + public $js = ''; + /** + * Editor configuration ('maxlength' drives the check-length toolbar button). Callers set this + * directly; declared so those writes are not deprecated dynamic-property creation on PHP 8.2+. + * + * @var array + */ + public $configs = []; + /** + * Constructor + * + * @param string $caption Caption + * @param string $name "name" attribute + * @param string $value Initial text + * @param int $rows Number of rows + * @param int $cols Number of columns + * @param string $hiddentext Identifier for hidden Text + * @param array $options Extra options + */ + public function __construct($caption, $name, $value = '', $rows = 5, $cols = 50, $hiddentext = 'xoopsHiddenText', $options = []) + { + } + /** + * Prepare HTML for output + * + * @return string HTML + */ + public function render() + { + } + /** + * XoopsFormDhtmlTextArea::renderValidationJS() + * + * @return bool|string + */ + public function renderValidationJS() + { + } +} +} + +namespace { +/** + * XOOPS Form Editor + * + */ +class XoopsFormEditor extends \XoopsFormTextArea +{ + public $editor; + /** + * Constructor + * + * @param string $caption Caption + * @param string $name Name for textarea field + * @param array $configs configures: editor - editor identifier; name - textarea field name; width, height - dimensions for textarea; value - text content + * @param bool $nohtml use non-WYSIWYG editor onfailure + * @param string $OnFailure editor to be used if current one failed + * + */ + public function __construct($caption, $name, $configs = \null, $nohtml = \false, $OnFailure = '') + { + } + /** + * renderValidationJS + * TEMPORARY SOLUTION to 'override' original renderValidationJS method + * with custom XoopsEditor's renderValidationJS method + */ + public function renderValidationJS() + { + } + /** + * XoopsFormEditor::render() + * + * @return string|null + */ + public function render() + { + } +} +} + +namespace { +/** + * Abstract base class for form elements + */ +class XoopsFormElement +{ + /** + * Javascript performing additional validation of this element data + * + * This property contains a list of Javascript snippets that will be sent to + * XoopsForm::renderValidationJS(). + * NB: All elements are added to the output one after the other, so don't forget + * to add a ";" after each to ensure no Javascript syntax error is generated. + * + * @var array () + */ + public $customValidationCode = []; + /** + * *#@+ + * + * @access private + */ + /** + * "name" attribute of the element + * + * @var string + */ + public $_name; + /** + * caption of the element + * + * @var string + */ + public $_caption; + /** + * Accesskey for this element + * + * @var string + */ + public $_accesskey = ''; + /** + * HTML classes for this element + * + * @var array + */ + public $_class = []; + /** + * hidden? + * + * @var bool + */ + public $_hidden = \false; + /** + * extra attributes to go in the tag + * + * @var array + */ + public $_extra = []; + /** + * required field? + * + * @var bool + */ + public $_required = \false; + /** + * description of the field + * + * @var string + */ + public $_description = ''; + /** + * *#@- + */ + /** + * CAtzwolf: Modified for No Colspan + * + * Lets a developer have only one column in a form element rather than two. + * Example of usage: Allows text editors to span 2 columns rather than pushed into one column + * + * @deprecated PLEASE AVOID USING THIS METHOD + * + * @var bool + */ + public $_nocolspan = \false; + /** + * Get form type + * + * @deprecated PLEASE AVOID USING THIS METHOD + */ + public $_formtype = ''; + /** + * constructor + */ + public function __construct() + { + } + /** + * Is this element a container of other elements? + * + * @return bool false + */ + public function isContainer() + { + } + /** + * set the "name" attribute for the element + * + * @param string $name "name" attribute for the element + */ + public function setName($name) + { + } + /** + * get the "name" attribute for the element + * + * @param bool $encode + * + * @return string "name" attribute + */ + public function getName($encode = \true) + { + } + /** + * set the "accesskey" attribute for the element + * + * @param string $key "accesskey" attribute for the element + */ + public function setAccessKey($key) + { + } + /** + * get the "accesskey" attribute for the element + * + * @return string "accesskey" attribute value + */ + public function getAccessKey() + { + } + /** + * If the accesskey is found in the specified string, underlines it + * + * @param string $str String where to search the accesskey occurrence + * @return string Enhanced string with the 1st occurrence of accesskey underlined + */ + public function getAccessString($str) + { + } + /** + * set the "class" attribute for the element + * + * @param string $class + */ + public function setClass($class) + { + } + /** + * get the "class" attribute for the element + * + * @return string|false "class" attribute value + */ + public function getClass() + { + } + /** + * set the caption for the element + * + * @param string $caption + */ + public function setCaption($caption) + { + } + /** + * get the caption for the element + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getCaption($encode = \false) + { + } + /** + * get the caption for the element + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getTitle($encode = \true) + { + } + /** + * set the element's description + * + * @param string $description + */ + public function setDescription($description) + { + } + /** + * get the element's description + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getDescription($encode = \false) + { + } + /** + * flag the element as "hidden" + */ + public function setHidden() + { + } + /** + * Find out if an element is "hidden". + * + * @return bool + */ + public function isHidden() + { + } + /** + * Find out if an element is required. + * + * @return bool + */ + public function isRequired() + { + } + /** + * Add extra attributes to the element. + * + * This string will be inserted verbatim and unvalidated in the + * element's tag. Know what you are doing! + * + * @param string $extra + * @param bool $replace If true, passed string will replace current content otherwise it will be appended to it + * @return array New content of the extra string + */ + public function setExtra($extra, $replace = \false) + { + } + /** + * Get the extra attributes for the element + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getExtra($encode = \false) + { + } + /** + * Set the element's nocolspan + * Modified by Catzwolf + * + * @param bool $nocolspan + * + * @deprecated PLEASE AVOID USING THIS METHOD + */ + public function setNocolspan($nocolspan = \true) + { + } + /** + * Get the element's nocolspan + * Modified by Catzwolf + * + * @return string|bool + * + * @deprecated PLEASE AVOID USING THIS METHOD + */ + public function getNocolspan() + { + } + /** + * get the element's nocolspan + * Modified by Catzwolf + * + * @return string + * + * @deprecated PLEASE AVOID USING THIS METHOD + */ + public function getFormType() + { + } + /** + * set the element's nocolspan + * Modified by Catzwolf + * + * @param string $value + * + * @deprecated PLEASE AVOID USING THIS METHOD + */ + public function setFormType($value = '') + { + } + /** + * Render custom javascript validation code + * + * @seealso XoopsForm::renderValidationJS + */ + public function renderValidationJS() + { + } + /** + * Generates output for the element. + * + * This method is abstract and must be overwritten by the child classes. + * + * @abstract + */ + public function render() + { + } +} +} + +namespace { +/** + * A group of form elements + */ +class XoopsFormElementTray extends \XoopsFormElement implements \XoopsFormContainerInterface +{ + public const ORIENTATION_HORIZONTAL = 'horizontal'; + public const ORIENTATION_VERTICAL = 'vertical'; + /** + * array of form element objects + * + * @var array + * @access private + */ + private $_elements = []; + /** + * required elements + * + * @var array + */ + public $_required = []; + protected $orientation; + /** + * HTML to separate the elements + * + * @var string + * @access private + */ + private $_delimeter; + /** + * constructor + * + * @param string $caption Caption for the group. + * @param string $delimeter HTML to separate the elements + * @param string $name + * + */ + public function __construct($caption, $delimeter = ' ', $name = '') + { + } + /** + * Is this element a container of other elements? + * + * @return bool true + */ + public function isContainer() + { + } + /** + * Find out if there are required elements. + * + * @return bool + */ + public function isRequired() + { + } + /** + * Add an element to the group + * + * @param XoopsFormElement $formElement {@link XoopsFormElement} to add + * @param bool $required + * + */ + public function addElement(\XoopsFormElement $formElement, $required = \false) + { + } + /** + * get an array of "required" form elements + * + * @return array array of {@link XoopsFormElement}s + */ + public function &getRequired() + { + } + /** + * Get an array of the elements in this group + * + * @param bool $recurse get elements recursively? + * @return XoopsFormElement[] Array of {@link XoopsFormElement} objects. + */ + public function &getElements($recurse = \false) + { + } + /** + * Get the delimiter of this group + * + * @param bool $encode To sanitizer the text? + * @return string The delimiter + */ + public function getDelimeter($encode = \false) + { + } + /** + * setOrientation() communicate to renderer the expected tray orientation + * \XoopsFormElementTray::ORIENTATION_HORIZONTAL for across + * \XoopsFormElementTray::ORIENTATION_VERTICAL for up and down + * + * If not set explicitly, a default value will be assigned on getOrientation() + * + * @param string $direction ORIENTATION constant + */ + public function setOrientation($direction) + { + } + /** + * getOrientation() return the expected tray orientation + * + * The value will be assigned a default value if not previously set. + * + * The default logic considers the presence of an HTML br tag in _delimeter + * as implying ORIENTATION_VERTICAL for bc + * + * @return string either \XoopsFormElementTray::ORIENTATION_HORIZONTAL + * or \XoopsFormElementTray::ORIENTATION_VERTICAL\ + */ + public function getOrientation() + { + } + /** + * prepare HTML to output this group + * + * @return string HTML output + */ + public function render() + { + } +} +} + +namespace { +/** + * A file upload field + */ +class XoopsFormFile extends \XoopsFormElement +{ + /** + * Maximum size for an uploaded file + * + * @var int + * @access private + */ + public $_maxFileSize; + /** + * Constructor + * + * @param string $caption Caption + * @param string $name "name" attribute + * @param int $maxfilesize Maximum size for an uploaded file + */ + public function __construct($caption, $name, $maxfilesize) + { + } + /** + * Get the maximum filesize + * + * @return int + */ + public function getMaxFileSize() + { + } + /** + * prepare HTML for output + * + * @return string HTML + */ + public function render() + { + } +} +} + +namespace { +/** + * A hidden field + */ +class XoopsFormHidden extends \XoopsFormElement +{ + /** + * Value + * + * @var string + * @access private + */ + public $_value; + /** + * Constructor + * + * @param string $name "name" attribute + * @param string $value "value" attribute + */ + public function __construct($name, $value) + { + } + /** + * Get the "value" attribute + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getValue($encode = \false) + { + } + /** + * Sets the "value" attribute + * + * @patam $value string + * @param $value + */ + public function setValue($value) + { + } + /** + * Prepare HTML for output + * + * @return string HTML + */ + public function render() + { + } +} +} + +namespace { +/** + * A text label + */ +class XoopsFormLabel extends \XoopsFormElement +{ + /** + * Text + * + * @var string + * @access private + */ + public $_value; + /** + * Constructor + * + * @param string $caption Caption + * @param string $value Text + * @param string $name + */ + public function __construct($caption = '', $value = '', $name = '') + { + } + /** + * Get the "value" attribute + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getValue($encode = \false) + { + } + /** + * Prepare HTML for output + * + * @return string + */ + public function render() + { + } +} +} + +namespace { +/** + * XOOPS form radio compo + * + * You may not change or alter any portion of this comment or credits + * of supporting developers from this source code or any supporting source code + * which is considered copyrighted (c) material of the original comment or credit authors. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html) + * @package kernel + * @since 2.0 + * @author Kazumi Ono (AKA onokazu) http://www.myweb.ne.jp/, http://jp.xoops.org/ + * @author Taiwen Jiang + * @package kernel + * @subpackage form + * @todo template + */ +class XoopsFormRadio extends \XoopsFormElement +{ + /** + * Array of Options + * + * @var array + * @access private + */ + public $_options = []; + /** + * Pre-selected value + * + * @var string + * @access private + */ + public $_value; + /** + * Columns per line for rendering + * Leave unset (null) to put all options in one line + * Set to 1 to put each option on its own line + * Any other positive integer 'n' to put 'n' options on each line + * + * @var int + * @access public + */ + public $columns; + /** + * HTML to separate the elements + * + * @var string + * @access private + */ + public $_delimeter; + /** + * Constructor + * + * @param string $caption Caption + * @param string $name "name" attribute + * @param string|null $value Pre-selected value + * @param string $delimeter + */ + public function __construct($caption, $name, $value = \null, $delimeter = ' ') + { + } + /** + * Get the "value" attribute + * + * @param bool $encode To sanitizer the text? + * @return string + */ + public function getValue($encode = \false) + { + } + /** + * Set the pre-selected value + * + * @param string $value + */ + public function setValue($value) + { + } + /** + * Add an option + * + * @param string $value "value" attribute - This gets submitted as form-data. + * @param string $name "name" attribute - This is displayed. If empty, we use the "value" instead. + */ + public function addOption($value, $name = '') + { + } + /** + * Adds multiple options + * + * @param array $options Associative array of value->name pairs. + */ + public function addOptionArray($options) + { + } + /** + * Get an array with all the options + * + * @param bool|int $encode To sanitizer the text? potential values: 0 - skip; 1 - only for value; 2 - for both value and name + * + * @return array Associative array of value->name pairs + */ + public function getOptions($encode = \false) + { + } + /** + * Get the delimiter of this group + * + * @param bool $encode To sanitizer the text? + * @return string The delimiter + */ + public function getDelimeter($encode = \false) + { + } + /** + * Prepare HTML for output + * + * @return string HTML + */ + public function render() + { + } +} +} + +namespace { +/** + * Yes/No radio buttons. + * + * A pair of radio buttons labelled _YES and _NO with values 1 and 0 + */ +class XoopsFormRadioYN extends \XoopsFormRadio +{ + /** + * Constructor + * + * @param string $caption + * @param string $name + * @param string $value Pre-selected value, can be "0" (No) or "1" (Yes) + * @param string $yes String for "Yes" + * @param string $no String for "No" + */ + public function __construct($caption, $name, $value = \null, $yes = \_YES, $no = \_NO) + { + } +} +} + +namespace { +/** + * A select field + * + * @author Kazumi Ono + * @author Taiwen Jiang + * @author John Neill + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + * @package kernel + * @subpackage form + * @access public + */ +class XoopsFormSelect extends \XoopsFormElement +{ + /** + * Options + * + * @var array + * @access private + */ + public $_options = []; + /** + * Allow multiple selections? + * + * @var bool + * @access private + */ + public $_multiple = \false; + /** + * Number of rows. "1" makes a dropdown list. + * + * @var int + * @access private + */ + public $_size; + /** + * Pre-selected values + * + * @var array + * @access private + */ + public $_value = []; + /** + * Constructor + * + * @param string $caption Caption + * @param string $name "name" attribute + * @param mixed $value Pre-selected value (or array of them). + * @param int $size Number of rows. "1" makes a drop-down-list + * @param bool $multiple Allow multiple selections? + */ + public function __construct($caption, $name, $value = \null, $size = 1, $multiple = \false) + { + } + /** + * Are multiple selections allowed? + * + * @return bool + */ + public function isMultiple() + { + } + /** + * Get the size + * + * @return int + */ + public function getSize() + { + } + /** + * Get an array of pre-selected values + * + * @param bool $encode To sanitizer the text? + * @return array + */ + public function getValue($encode = \false) + { + } + /** + * Set pre-selected values + * + * @param mixed $value + */ + public function setValue($value) + { + } + /** + * Add an option + * + * @param string $value "value" attribute + * @param string $name "name" attribute + */ + public function addOption($value, $name = '') + { + } + /** + * Add multiple options + * + * @param array $options Associative array of value->name pairs + */ + public function addOptionArray($options) + { + } + /** + * Get an array with all the options + * + * Note: both name and value should be sanitized. However, for backward compatibility, only value is sanitized for now. + * + * @param bool|int $encode To sanitizer the text? potential values: 0 - skip; 1 - only for value; 2 - for both value and name + * + * @return array Associative array of value->name pairs + */ + public function getOptions($encode = \false) + { + } + /** + * Prepare HTML for output + * + * @return string HTML + */ + public function render() + { + } + /** + * Render custom javascript validation code + * + * @seealso XoopsForm::renderValidationJS + */ + public function renderValidationJS() + { + } +} +} + +namespace { +/** + * A select field with a choice of available groups + */ +class XoopsFormSelectGroup extends \XoopsFormSelect +{ + /** + * Constructor + * + * @param string $caption + * @param string $name + * @param bool $include_anon Include group "anonymous"? + * @param mixed $value Pre-selected value (or array of them). + * @param int $size Number of rows. "1" makes a drop-down-list. + * @param bool $multiple Allow multiple selections? + */ + public function __construct($caption, $name, $include_anon = \false, $value = \null, $size = 1, $multiple = \false) + { + } +} +} + +namespace { +/** + * User Select field + */ +class XoopsFormSelectUser extends \XoopsFormElementTray +{ + /** @var int Maximum users to show in the dropdown */ + private static $limit = 200; + /** @var string Cache TTL interpreted by strtotime() */ + private static $cacheTtl = '+5 minutes'; + /** + * Sanitize allowed group IDs to strict positive integers. + * + * Rejects non-digit values entirely (intval('1foo') would silently become 1). + * Fails closed with [0] (impossible group) when a non-empty input sanitizes to empty. + * + * @param array $allowedGroups Raw group ID values + * + * @return array Sanitized group IDs, sorted + */ + private static function normalizeAllowedGroups(array $allowedGroups): array + { + } + /** + * Build the user option list, using cache when available. + * + * @param XoopsMemberHandler $memberHandler Member handler + * @param array $allowedGroups Sanitized group IDs (empty = no restriction) + * @param string $cachekey Cache key for this group set + * @param array $queryCache Reference to static query cache + * + * @return array uid => uname pairs + */ + private static function getUserOptions(\XoopsMemberHandler $memberHandler, array $allowedGroups, string $cachekey, array &$queryCache): array + { + } + /** + * Filter pre-selected values to only include users in allowed groups. + * + * @param array $selectedUsers uid => uname of pre-selected users + * @param array $allowedGroups Sanitized group IDs + * @param XoopsMemberHandler $memberHandler Member handler + * + * @return array Filtered uid => uname pairs + */ + private static function filterSelectedByGroups(array $selectedUsers, array $allowedGroups, \XoopsMemberHandler $memberHandler): array + { + } + /** + * Constructor + * + * @param string $caption form element caption + * @param string $name form element name + * @param bool $includeAnonymous Include user "anonymous"? + * @param mixed $value Pre-selected value (or array of them). + * For an item with massive members, such as "Registered Users", "$value" + * should be used to store selected temporary users only instead of all + * members of that item + * @param int $size Number of rows. "1" makes a drop-down-list. + * @param bool $multiple Allow multiple selections? + * @param array $allowedGroups Group IDs to restrict users to; empty means no restriction. + * @param array $extraQuery Extra query parameters appended to the findusers.php popup URL. + */ + public function __construct($caption, $name, $includeAnonymous = \false, $value = \null, $size = 1, $multiple = \false, array $allowedGroups = [], array $extraQuery = []) + { + } +} +} + +namespace { +/** + * A simple text field + */ +class XoopsFormText extends \XoopsFormElement +{ + /** + * Size + * + * @var int + * @access private + */ + public $_size; + /** + * Maximum length of the text + * + * @var int + * @access private + */ + public $_maxlength; + /** + * Initial text + * + * @var string + * @access private + */ + public $_value; + /** + * Constructor + * + * @param string $caption Caption + * @param string $name "name" attribute + * @param int $size Size + * @param int $maxlength Maximum length of text + * @param string $value Initial text + */ + public function __construct($caption, $name, $size, $maxlength, $value = '') + { + } + /** + * Get size + * + * @return int + */ + public function getSize() + { + } + /** + * Get maximum text length + * + * @return int + */ + public function getMaxlength() + { + } + /** + * Get initial content + * + * @param bool $encode To sanitizer the text? Default value should be "true"; however we have to set "false" for backward compatibility + * @return string + */ + public function getValue($encode = \false) + { + } + /** + * Set initial text value + * + * @param string $value + */ + public function setValue($value) + { + } + /** + * Prepare HTML for output + * + * @return string HTML + */ + public function render() + { + } +} +} + +namespace { +/** + * A textarea + */ +class XoopsFormTextArea extends \XoopsFormElement +{ + /** + * number of columns + * + * @var int + * @access private + */ + public $_cols; + /** + * number of rows + * + * @var int + * @access private + */ + public $_rows; + /** + * initial content + * + * @var string + * @access private + */ + public $_value; + /** + * Constructor + * + * @param string $caption caption + * @param string $name name + * @param string $value initial content + * @param int $rows number of rows + * @param int $cols number of columns + */ + public function __construct($caption, $name, $value = '', $rows = 5, $cols = 50) + { + } + /** + * get number of rows + * + * @return int + */ + public function getRows() + { + } + /** + * Get number of columns + * + * @return int + */ + public function getCols() + { + } + /** + * Get initial content + * + * @param bool $encode To sanitizer the text? Default value should be "true"; however we have to set "false" for backward compatibility + * @return string + */ + public function getValue($encode = \false) + { + } + /** + * Set initial content + * + * @param string $value + */ + public function setValue($value) + { + } + /** + * prepare HTML for output + * + * @return string HTML + */ + public function render() + { + } +} +} + +namespace { +/** + * XOOPS group permission handler class. + * + * This class is responsible for providing data access mechanisms to the data source + * of XOOPS group permission class objects. + * This class is an abstract class to be implemented by child group permission classes. + * + * @see XoopsGroupPerm + * @author Kazumi Ono + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + */ +class XoopsGroupPermHandler extends \XoopsObjectHandler +{ + /** + * This should be here, since this really should be a XoopsPersistableObjectHandler + * Here, we fake it for future compatibility + * + * @var string table name + */ + public $table; + public function __construct(\XoopsDatabase $db) + { + } + /** + * Create a new {@link XoopsGroupPerm} + * + * @param bool $isNew Flag the object as "new"? + * + * @return XoopsGroupPerm + */ + public function create($isNew = \true) + { + } + /** + * Retrieve a group permission + * + * @param int $id ID + * + * @return XoopsGroupPerm|false {@link XoopsGroupPerm}, false on fail + */ + public function get($id) + { + } + /** + * Store a {@link XoopsGroupPerm} + * + * @param XoopsObject|XoopsGroupPerm $perm a XoopsGroupPerm object + * + * @return bool true on success, otherwise false + */ + public function insert(\XoopsObject $perm) + { + } + /** + * Delete a {@link XoopsGroupPerm} + * + * @param XoopsObject|XoopsGroupPerm $perm a XoopsGroupPerm object + * + * @return bool true on success, otherwise false + */ + public function delete(\XoopsObject $perm) + { + } + /** + * Retrieve multiple {@link XoopsGroupPerm}s + * + * @param CriteriaElement|CriteriaCompo $criteria {@link CriteriaElement} + * @param bool $id_as_key Use IDs as array keys? + * + * @return array Array of {@link XoopsGroupPerm}s + */ + public function getObjects(?\CriteriaElement $criteria = \null, $id_as_key = \false) + { + } + /** + * Count some {@link XoopsGroupPerm}s + * + * @param CriteriaElement|CriteriaCompo $criteria {@link CriteriaElement} + * + * @return int + */ + public function getCount(?\CriteriaElement $criteria = \null) + { + } + /** + * Delete all permissions by a certain criteria + * + * @param CriteriaElement|CriteriaCompo $criteria {@link CriteriaElement} + * + * @return bool TRUE on success + */ + public function deleteAll(?\CriteriaElement $criteria = \null) + { + } + /** + * Delete all module specific permissions assigned for a group + * + * @param int $gperm_groupid ID of a group + * @param int $gperm_modid ID of a module + * + * @return bool TRUE on success + */ + public function deleteByGroup($gperm_groupid, $gperm_modid = \null) + { + } + /** + * Delete all module specific permissions + * + * @param int $gperm_modid ID of a module + * @param string $gperm_name Name of a module permission + * @param int $gperm_itemid ID of a module item + * + * @return bool TRUE on success + */ + public function deleteByModule($gperm_modid, $gperm_name = \null, $gperm_itemid = \null) + { + } + /** + * Check permission + * + * @param string $gperm_name Name of permission + * @param int $gperm_itemid ID of an item + * @param int /array $gperm_groupid A group ID or an array of group IDs + * @param int $gperm_modid ID of a module + * @param bool $trueifadmin Returns true for admin groups + * + * @return bool TRUE if permission is enabled + */ + public function checkRight($gperm_name, $gperm_itemid, $gperm_groupid, $gperm_modid = 1, $trueifadmin = \true) + { + } + /** + * Add a permission + * + * @param string $gperm_name Name of permission + * @param int $gperm_itemid ID of an item + * @param int $gperm_groupid ID of a group + * @param int $gperm_modid ID of a module + * + * @return bool TRUE if success + */ + public function addRight($gperm_name, $gperm_itemid, $gperm_groupid, $gperm_modid = 1) + { + } + /** + * Get all item IDs that a group is assigned a specific permission + * + * @param string $gperm_name Name of permission + * @param int /array $gperm_groupid A group ID or an array of group IDs + * @param int $gperm_modid ID of a module + * + * @return array array of item IDs + */ + public function getItemIds($gperm_name, $gperm_groupid, $gperm_modid = 1) + { + } + /** + * Get all group IDs assigned a specific permission for a particular item + * + * @param string $gperm_name Name of permission + * @param int $gperm_itemid ID of an item + * @param int $gperm_modid ID of a module + * + * @return array array of group IDs + */ + public function getGroupIds($gperm_name, $gperm_itemid, $gperm_modid = 1) + { + } +} +} + +namespace { +/** + * Upload Media files + * + * Example of usage (single file): + * + * include_once __DIR__ . '/uploader.php'; + * $allowed_mimetypes = array('image/gif', 'image/jpeg', 'image/pjpeg', 'image/x-png'); + * $maxfilesize = 50000; + * $maxfilewidth = 120; + * $maxfileheight = 120; + * $randomFilename = true; + * $uploader = new XoopsMediaUploader('/home/xoops/uploads', $allowed_mimetypes, $maxfilesize, $maxfilewidth, $maxfileheight, $randomFilename); + * if ($uploader->fetchMedia('single_file_name')) { + * if (!$uploader->upload()) { + * echo $uploader->getErrors(); + * } else { + * echo '

    File uploaded successfully!

    ' + * echo 'Saved as: ' . $uploader->getSavedFileName() . '
    '; + * echo 'Full path: ' . $uploader->getSavedDestination(); + * } + * } else { + * echo $uploader->getErrors(); + * } + *
    + * + * Example of usage (multiple file): + * + * include_once __DIR__ . '/uploader.php'; + * $allowed_mimetypes = array('image/gif', 'image/jpeg', 'image/pjpeg', 'image/x-png', 'image/webp'); + * $maxfilesize = 50000; + * $maxfilewidth = 120; + * $maxfileheight = 120; + * $randomFilename = true; + * $uploader = new XoopsMediaUploader('/home/xoops/uploads', $allowed_mimetypes, $maxfilesize, $maxfilewidth, $maxfileheight, $randomFilename); + * for ($i = 0; $i < $uploader->countMedia('multiple_file_name'); $i++) { + * if ($uploader->fetchMedia('multiple_file_name')) { + * if (!$uploader->upload()) { + * echo $uploader->getErrors(); + * } else { + * echo '

    File uploaded successfully!

    ' + * echo 'Saved as: ' . $uploader->getSavedFileName() . '
    '; + * echo 'Full path: ' . $uploader->getSavedDestination(); + * } + * } else { + * echo $uploader->getErrors(); + * } + * } + *
    + * + */ +class XoopsMediaUploader +{ + /** + * Flag indicating if unrecognized mimetypes should be allowed (use with precaution ! may lead to security issues ) + */ + public $allowUnknownTypes = \false; + public $mediaName; + public $mediaType; + public $mediaSize; + public $mediaTmpName; + public $mediaError; + public $mediaRealType = ''; + public $uploadDir = ''; + public $allowedMimeTypes = []; + public $deniedMimeTypes = ['application/x-httpd-php']; + public $maxFileSize = 0; + public $maxWidth; + public $maxHeight; + public $targetFileName; + public $prefix; + public $errors = []; + public $savedDestination; + public $savedFileName; + public $extensionToMime = []; + public $checkImageType = \true; + public $extensionsToBeSanitized = ['php', 'phtml', 'phtm', 'pht', 'php3', 'php4', 'php5', 'php7', 'php8', 'phar', 'shtml', 'cgi', 'pl', 'asp']; + // extensions needed image check (anti-IE Content-Type XSS) + public $imageExtensions = [1 => 'gif', 2 => 'jpg', 3 => 'png', 4 => 'swf', 5 => 'psd', 6 => 'bmp', 7 => 'tif', 8 => 'tif', 9 => 'jpc', 10 => 'jp2', 11 => 'jpx', 12 => 'jb2', 13 => 'swc', 14 => 'iff', 15 => 'wbmp', 16 => 'xbm', 17 => 'webp']; + public $randomFilename = \false; + /** + * Constructor + * + * @param string $uploadDir + * @param array $allowedMimeTypes + * @param int $maxFileSize + * @param int $maxWidth + * @param int $maxHeight + * @param bool $randomFilename + */ + public function __construct($uploadDir, $allowedMimeTypes, $maxFileSize = 0, $maxWidth = \null, $maxHeight = \null, $randomFilename = \false) + { + } + /** + * converts memory/file sizes as defined in php.ini to bytes + * + * @param $size_str + * + * @return int + */ + public function return_bytes($size_str) + { + } + /** + * Count the uploaded files (in case of multiple upload) + * + * @param string $media_name Name of the file field + * @return int|false + */ + public function countMedia($media_name) + { + } + /** + * Fetch the uploaded file + * + * @param string $media_name Name of the file field + * @param int $index Index of the file (if more than one uploaded under that name) + * @return bool + */ + public function fetchMedia($media_name, $index = \null) + { + } + /** + * Set the target filename + * + * @param string $value + */ + public function setTargetFileName($value) + { + } + /** + * Set the prefix + * + * @param string $value + */ + public function setPrefix($value) + { + } + /** + * Get the uploaded filename + * + * @return string + */ + public function getMediaName() + { + } + /** + * Get the type of the uploaded file + * + * @return string + */ + public function getMediaType() + { + } + /** + * Get the size of the uploaded file + * + * @return int + */ + public function getMediaSize() + { + } + /** + * Get the temporary name that the uploaded file was stored under + * + * @return string + */ + public function getMediaTmpName() + { + } + /** + * Get the saved filename + * + * @return string + */ + public function getSavedFileName() + { + } + /** + * Get the destination the file is saved to + * + * @return string + */ + public function getSavedDestination() + { + } + /** + * Check the file and copy it to the destination + * + * @param int $chmod + * @return bool + */ + public function upload($chmod = 0644) + { + } + /** + * Copy the file to its destination + * + * @param int $chmod + * @return bool + */ + public function _copyFile($chmod) + { + } + /** + * Is the file the right size? + * + * @return bool + */ + public function checkMaxFileSize() + { + } + /** + * Is the picture the right width? + * + * @return bool + */ + public function checkMaxWidth() + { + } + /** + * Is the picture the right height? + * + * @return bool + */ + public function checkMaxHeight() + { + } + /** + * Check whether or not the uploaded file type is allowed + * + * @return bool + */ + public function checkMimeType() + { + } + /** + * Check whether or not the uploaded image type is valid + * + * @return bool + */ + public function checkImageType() + { + } + /** + * Sanitize executable filename with multiple extensions + */ + public function sanitizeMultipleExtensions() + { + } + /** + * Add an error + * + * @param string $error + */ + public function setErrors($error) + { + } + /** + * Get generated errors + * + * @param bool $ashtml Format using HTML? + * @return array |string Array of array messages OR HTML string + */ + public function &getErrors($ashtml = \true) + { + } + /** + * Push value onto set. + * Used in max file size calculation to eliminate -1 (unlimited) ini values + * + * @param array $set array of values + * @param int $value value to push + * + * @return mixed + */ + protected function arrayPushIfPositive($set, $value) + { + } +} +} + +namespace { +/** + * XOOPS member handler class. + * This class provides simple interface (a facade class) for handling groups/users/ + * membership data with enhanced security and performance optimizations. + * + * @package kernel + */ +class XoopsMemberHandler +{ + // Security constants + private const BIDI_CONTROL_REGEX = '/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u'; + private const SENSITIVE_PARAMS = ['token', 'access_token', 'id_token', 'password', 'pass', 'pwd', 'secret', 'key', 'api_key', 'apikey', 'auth', 'authorization', 'session', 'sid', 'code', 'csrf', 'nonce']; + private const MAX_BATCH_SIZE = 1000; + private const LOG_CONTEXT_MAX_LENGTH = 256; + /** + * @var XoopsGroupHandler Reference to group handler(DAO) class + */ + protected $groupHandler; + /** + * @var XoopsUserHandler Reference to user handler(DAO) class + */ + protected $userHandler; + /** + * @var XoopsMembershipHandler Reference to membership handler(DAO) class + */ + protected $membershipHandler; + /** + * Token handler used to drop an account's tokens when the account is deleted. + * Null only when the connection is not the MySQL one every supported site uses. + * + * @var XoopsTokenHandler|null + */ + protected ?\XoopsTokenHandler $tokenHandler = \null; + /** + * @var XoopsUser2faHandler|null + */ + protected ?\XoopsUser2faHandler $user2faHandler = \null; + /** + * @var array Temporary user objects cache + */ + protected $membersWorkingList = []; + /** + * Per-request memo for getGroupList() when called without criteria. + * + * The unfiltered list is requested repeatedly during a single render — the xoops2020 + * theme drives publisher's ItemHandler once per section and each call asked again, + * producing ~50 identical "SELECT * FROM groups" per page. The set cannot change + * mid-request except through insertGroup()/deleteGroup(), which both clear this. + * + * Only the no-criteria case is memoised; filtered calls (3 sites in the tree) still + * query, so no criteria-keying subtleties are introduced. + * + * @var array|null + */ + protected $groupListCache; + /** + * Constructor + * @param XoopsDatabase $db Database connection object + */ + public function __construct(\XoopsDatabase $db) + { + } + /** + * The token handler for a connection. + * + * Tokens need the concrete MySQL connection; every supported connection + * is one, and without it there are no tokens to delete. + * + * @param XoopsDatabase $db Database connection object + * @return XoopsTokenHandler|null + */ + protected static function tokenHandlerFor(\XoopsDatabase $db): ?\XoopsTokenHandler + { + } + /** + * The second-factor handler for a connection. + * + * Same rule as the tokens: without the concrete MySQL connection there + * is no factor row to delete. + * + * @param XoopsDatabase $db Database connection object + * @return XoopsUser2faHandler|null + */ + protected static function user2faHandlerFor(\XoopsDatabase $db): ?\XoopsUser2faHandler + { + } + /** + * Create a new group + * @return XoopsGroup Reference to the new group + */ + public function &createGroup() + { + } + /** + * Create a new user + * @return XoopsUser Reference to the new user + */ + public function createUser() + { + } + /** + * Retrieve a group + * @param int $id ID for the group + * @return XoopsGroup|false XoopsGroup reference to the group or false + */ + public function getGroup($id) + { + } + /** + * Retrieve a user (with caching) + * @param int $id ID for the user + * @return XoopsUser|false Reference to the user or false + */ + public function getUser($id) + { + } + /** + * Delete a group + * @param XoopsGroup $group Reference to the group to delete + * @return bool TRUE on success, FALSE on failure + */ + public function deleteGroup(\XoopsGroup $group) + { + } + /** + * Delete a user + * @param XoopsUser $user Reference to the user to delete + * @return bool TRUE on success, FALSE on failure + */ + public function deleteUser(\XoopsUser $user) + { + } + /** + * Insert a group into the database + * @param XoopsGroup $group Reference to the group to insert + * @return bool TRUE on success, FALSE on failure + */ + public function insertGroup(\XoopsGroup $group) + { + } + /** + * Insert a user into the database + * @param XoopsUser $user Reference to the user to insert + * @param bool $force Force insertion even if user already exists + * @return bool TRUE on success, FALSE on failure + */ + public function insertUser(\XoopsUser $user, $force = \false) + { + } + /** + * Retrieve groups from the database + * @param CriteriaElement|null $criteria {@link CriteriaElement} + * @param bool $id_as_key Use the group's ID as key for the array? + * @return array Array of {@link XoopsGroup} objects + */ + public function getGroups($criteria = \null, $id_as_key = \false) + { + } + /** + * Retrieve users from the database + * @param CriteriaElement|null $criteria {@link CriteriaElement} + * @param bool $id_as_key Use the user's ID as key for the array? + * @return array Array of {@link XoopsUser} objects + */ + public function getUsers($criteria = \null, $id_as_key = \false) + { + } + /** + * Get a list of groupnames and their IDs + * @param CriteriaElement|null $criteria {@link CriteriaElement} object + * @return array Associative array of group-IDs and names + */ + public function getGroupList($criteria = \null) + { + } + /** + * Get a list of usernames and their IDs + * @param CriteriaElement|null $criteria {@link CriteriaElement} object + * @return array Associative array of user-IDs and names + */ + public function getUserList($criteria = \null) + { + } + /** + * Add a user to a group + * @param int $group_id ID of the group + * @param int $user_id ID of the user + * @return XoopsMembership|bool XoopsMembership object on success, FALSE on failure + */ + public function addUserToGroup($group_id, $user_id) + { + } + /** + * Remove a list of users from a group + * @param int $group_id ID of the group + * @param array $user_ids Array of user-IDs + * @return bool TRUE on success, FALSE on failure + */ + public function removeUsersFromGroup($group_id, $user_ids = []) + { + } + /** + * Get a list of users belonging to a group + * @param int $group_id ID of the group + * @param bool $asobject Return the users as objects? + * @param int $limit Number of users to return + * @param int $start Index of the first user to return + * @return array Array of {@link XoopsUser} objects or user IDs + */ + public function getUsersByGroup($group_id, $asobject = \false, $limit = 0, $start = 0) + { + } + /** + * Get a list of groups that a user is member of + * @param int $user_id ID of the user + * @param bool $asobject Return groups as {@link XoopsGroup} objects or arrays? + * @return array Array of objects or arrays + */ + public function getGroupsByUser($user_id, $asobject = \false) + { + } + /** + * Log in a user with enhanced security + * @param string $uname Username as entered in the login form + * @param string $pwd Password entered in the login form + * @return XoopsUser|false Logged in XoopsUser, FALSE if failed to log in + */ + public function loginUser($uname, $pwd) + { + } + /** + * Get maximum character length for a table column + * @param string $table Database table name + * @param string $column Table column name + * @return int|null Max length or null on error + */ + public function getColumnCharacterLength($table, $column) + { + } + /** + * Count users matching certain conditions + * @param CriteriaElement|null $criteria {@link CriteriaElement} object + * @return int Number of users + */ + public function getUserCount($criteria = \null) + { + } + /** + * Count users belonging to a group + * @param int $group_id ID of the group + * @return int Number of users in the group + */ + public function getUserCountByGroup($group_id) + { + } + /** + * Update a single field in a user's record + * @param XoopsUser $user Reference to the {@link XoopsUser} object + * @param string $fieldName Name of the field to update + * @param mixed $fieldValue Updated value for the field + * @return bool TRUE if success or unchanged, FALSE on failure + */ + public function updateUserByField(\XoopsUser $user, $fieldName, $fieldValue) + { + } + /** + * Update a single field for multiple users + * @param string $fieldName Name of the field to update + * @param mixed $fieldValue Updated value for the field + * @param CriteriaElement|null $criteria {@link CriteriaElement} object + * @return bool TRUE if success or unchanged, FALSE on failure + */ + public function updateUsersByField($fieldName, $fieldValue, $criteria = \null) + { + } + /** + * Activate a user + * @param XoopsUser $user Reference to the {@link XoopsUser} object + * @return bool TRUE on success, FALSE on failure + */ + public function activateUser(\XoopsUser $user) + { + } + /** + * Get a list of users belonging to certain groups and matching criteria + * @param int|array $groups IDs of groups + * @param CriteriaElement|null $criteria {@link CriteriaElement} object + * @param bool $asobject Return the users as objects? + * @param bool $id_as_key Use the UID as key for the array if $asobject is TRUE + * @return array Array of {@link XoopsUser} objects or user IDs + */ + public function getUsersByGroupLink($groups, $criteria = \null, $asobject = \false, $id_as_key = \false) + { + } + /** + * Get count of users belonging to certain groups and matching criteria + * @param array $groups IDs of groups + * @param CriteriaElement|null $criteria {@link CriteriaElement} object + * @return int Count of users + */ + public function getUserCountByGroupLink(array $groups, $criteria = \null) + { + } + // ========================================================================= + // Private Helper Methods + // ========================================================================= + /** + * Create a safe IN criteria for IDs + * @param string $field Field name + * @param mixed $value Single value or array of values + * @return CriteriaElement + */ + private function createSafeInCriteria($field, $value) + { + } + /** + * Sanitize an array of IDs + * @param array $ids Array of potential IDs + * @return array Array of valid integer IDs + */ + private function sanitizeIds(array $ids) + { + } + /** + * Get allowed sort fields for SQL queries + * @return array Map of allowed field names to SQL columns + */ + private function getAllowedSortFields() + { + } + /** + * Timing-safe string comparison + * @param string $expected Expected string + * @param string $actual Actual string + * @return bool TRUE if strings are equal + */ + private function hashEquals(string $expected, string $actual): bool + { + } + /** + * Check if debug output is allowed + * @return bool TRUE if debugging is allowed + */ + private function isDebugAllowed() + { + } + /** + * Check if running in production environment + * @return bool TRUE if in production + */ + private function isProductionEnvironment() + { + } + /** + * Redact sensitive information from SQL queries + * @param string $sql SQL query string + * @return string Redacted SQL query + */ + private function redactSql($sql) + { + } + /** + * Sanitize string for logging + * @param string $str String to sanitize + * @return string Sanitized string + */ + private function sanitizeForLog($str) + { + } + /** + * Log database errors with context + * @param string $message Error message + * @param string $sql SQL query that failed + * @param array $context Additional context + * @param bool $isDebug Whether debug mode is enabled + * @return void + */ + private function logDatabaseError($message, $sql, array $context, $isDebug) + { + } + /** + * Sanitize request URI for logging + * @return string Sanitized URI + */ + private function sanitizeRequestUri() + { + } + /** + * Log security-related events + * @param string $event Event description + * @param array $context Additional context + */ + private function logSecurityEvent($event, array $context = []) + { + } +} +} + +namespace { +/** + * A Module + * + * @package kernel + * @author Kazumi Ono + */ +class XoopsModule extends \XoopsObject +{ + /** + * + * @var string + */ + public $modinfo; + /** + * + * @var array + */ + public $adminmenu; + /** + * + * @var array + */ + public $_msg; + //PHP 8.2 Dynamic properties deprecated + public $mid; + public $name; + public $version; + public $last_update; + public $weight; + public $show_in_menu; + public $isactive; + public $dirname; + public $hasmain; + public $hasadmin; + public $hassearch; + public $hasconfig; + public $hascomments; + // RMV-NOTIFY + public $hasnotification; + /** + * Constructor + */ + public function __construct() + { + } + /** + * Assign DB row values, deriving show_in_menu from weight on pre-2.1.10 schemas. + * + * @param array $var_arr associative array of values from DB row + */ + public function assignVars($var_arr) + { + } + /** + * Load module info + * + * @param string $dirname Directory Name + * @param boolean $verbose + */ + public function loadInfoAsVar($dirname, $verbose = \true) + { + } + /** + * add a message + * + * @param string $str message to add + * @access public + */ + public function setMessage($str) + { + } + /** + * return the messages for this object as an array + * + * @return array an array of messages + * @access public + */ + public function getMessages() + { + } + /** + * Set module info + * + * @param string $name + * @param mixed $value + * @return bool + **/ + public function setInfo($name, $value) + { + } + /** + * Get module info + * + * @param string $name + * @return array|string Array of module information. + * If {@link $name} is set, returns a single module information item as string. + */ + public function &getInfo($name = \null) + { + } + /** + * Get status + * + * @return string + */ + public function getStatus() + { + } + /** + * Compares two "XOOPS-standardized" version number strings. + * + * @param string $version1 + * @param string $version2 + * @param string $operator + * @return boolean The function will return true if the relationship is the one specified by the operator, false otherwise. + */ + public function versionCompare($version1 = '', $version2 = '', $operator = '<'): bool + { + } + /** + * Get a link to the modules main page + * + * @return string FALSE on fail + */ + public function mainLink() + { + } + /** + * Get links to the subpages + * + * @return array + */ + public function subLink() + { + } + /** + * Load the admin menu for the module + */ + public function loadAdminMenu() + { + } + /** + * Get the admin menu for the module + * + * @return array + */ + public function &getAdminMenu() + { + } + /** + * Load the module info for this module + * + * @param string $dirname Module directory + * @param bool $verbose Give an error on fail? + * + * @return bool true if loaded + */ + public function loadInfo($dirname, $verbose = \true) + { + } + /** + * Search contents within a module + * + * @param string $term + * @param string $andor 'AND' or 'OR' + * @param integer $limit + * @param integer $offset + * @param integer $userid + * @return mixed Search result. + */ + public function search($term = '', $andor = 'AND', $limit = 0, $offset = 0, $userid = 0) + { + } + /** + * Returns Class Base Variable mid + * @param string $format + * @return mixed + */ + public function id($format = 'N') + { + } + /** + * Returns Class Base Variable mid + * @param string $format + * @return mixed + */ + public function mid($format = '') + { + } + /** + * Resolve a stored module-name key for every legacy getVar() display caller. + * + * All other variables retain the parent implementation unchanged. + * Persistence paths must use rawName() so a resolved translation is never + * written back over the stable key. + * + * @param string $key + * @param string|null $format + * @return mixed + */ + public function getVar($key, ?string $format = \null) + { + } + /** @return mixed module name exactly as stored, without token resolution */ + public function rawName(?string $format = \null) + { + } + /** + * Returns Class Base Variable name + * @param string $format + * @return mixed + */ + public function name($format = '') + { + } + /** + * Returns Class Base Variable version + * @param string $format + * @return mixed + */ + public function version($format = '') + { + } + /** + * Returns Class Base Variable last_update + * @param string $format + * @return mixed + */ + public function last_update($format = '') + { + } + /** + * Returns Class Base Variable weight + * @param string $format + * @return mixed + */ + public function weight($format = '') + { + } + /** + * Returns Class Base Variable isactive + * @param string $format + * @return mixed + */ + public function isactive($format = '') + { + } + /** + * Returns Class Base Variable dirname + * @param string $format + * @return mixed + */ + public function dirname($format = '') + { + } + /** + * Returns Class Base Variable hasmain + * @param string $format + * @return mixed + */ + public function hasmain($format = '') + { + } + /** + * Returns Class Base Variable hasadmin + * @param string $format + * @return mixed + */ + public function hasadmin($format = '') + { + } + /** + * Returns Class Base Variable hassearch + * @param string $format + * @return mixed + */ + public function hassearch($format = '') + { + } + /** + * Returns Class Base Variable hasconfig + * @param string $format + * @return mixed + */ + public function hasconfig($format = '') + { + } + /** + * Returns Class Base Variable hascomments + * @param string $format + * @return mixed + */ + public function hascomments($format = '') + { + } + /** + * Returns Class Base Variable hasnotification + * @param string $format + * @return mixed + */ + public function hasnotification($format = '') + { + } + /** + * @param $dirname + * + * @return mixed + */ + public static function getByDirname($dirname) + { + } + /** + * Returns Class Base Variable icon + * @param string $format + * @return mixed + */ + public function icon($format = '') + { + } + ##################### Deprecated Methods ###################### + /**#@+ + * @deprecated + */ + public function checkAccess() + { + } + /** + * @param string $type + * + * @return bool + * @deprecated + */ + public function loadLanguage($type = 'main') + { + } + /** + * @return bool + * @deprecated + */ + public function loadErrorMessages() + { + } + /** + * @return bool + * @deprecated + */ + public function getCurrentPage() + { + } + /** + * @param array $admingroups + * @param array $accessgroups + * + * @return bool + * @deprecated + */ + public function install($admingroups = [], $accessgroups = []) + { + } + /** + * @return bool + * @deprecated + */ + public function update() + { + } + /** + * @return bool + * @deprecated + */ + public function insert() + { + } + /** + * @return bool + */ + public function executeSQL() + { + } + /** + * @return bool + * @deprecated + */ + public function insertTemplates() + { + } + /** + * @param $template + * @param bool $block + * + * @return bool + * @deprecated + */ + public function gettemplate($template, $block = \false) + { + } + /** + * @return bool + * @deprecated + */ + public function insertBlocks() + { + } + /** + * @return bool + * @deprecated + */ + public function insertConfigCategories() + { + } + /** + * @return bool + * @deprecated + */ + public function insertConfig() + { + } + /** + * @return bool + * @deprecated + */ + public function insertProfileFields() + { + } + /** + * @param $type + * @param int $state + * + * @return bool + * @deprecated + */ + public function executeScript($type, $state = 2) + { + } + /** + * @param $groups + * @param $type + * + * @return bool + * @deprecated + */ + public function insertGroupPermissions($groups, $type) + { + } +} +} + +namespace { +/** + * XOOPS module handler class. + * + * This class is responsible for providing data access mechanisms to the data source + * of XOOPS module class objects. + * + * @package kernel + * @author Kazumi Ono + * @copyright (c) 2000-2016 XOOPS Project - www.xoops.org + * + * @todo Why is this not a XoopsPersistableObjectHandler? + */ +class XoopsModuleHandler extends \XoopsObjectHandler +{ + /** + * holds an array of cached module references, indexed by module id + * + * @var array + * @access private + */ + public $_cachedModule_mid = []; + /** + * holds an array of cached module references, indexed by module dirname + * + * @var array + * @access private + */ + public $_cachedModule_dirname = []; + /** + * Create a new {@link XoopsModule} object + * + * @param boolean $isNew Flag the new object as "new" + * @return XoopsModule + */ + public function create($isNew = \true) + { + } + /** + * Load a module from the database + * + * @param int $id ID of the module + * @return XoopsObject|false false on fail + */ + public function get($id) + { + } + /** + * Load a module by its dirname + * + * @param string $dirname + * @return XoopsModule|FALSE on fail + */ + public function getByDirname($dirname) + { + } + /** @var bool|null Cached column existence check; null = not yet probed */ + private static $hasShowInMenu = \null; + /** + * Check whether the modules table has the show_in_menu column. + * + * Cached per-request. Use resetShowInMenuCache() after ALTER TABLE + * to re-probe within the same process (e.g. during upgrade). + * + * @param XoopsMySQLDatabase $db + * + * @return bool + */ + private static function hasShowInMenuColumn($db): bool + { + } + /** + * Reset the cached column check so subsequent insert() calls + * pick up schema changes made during the same request. + */ + public static function resetShowInMenuCache(): void + { + } + /** + * Write a module to the database + * + * @param XoopsObject|XoopsModule $module a XoopsModule object + * + * @return bool true on success, otherwise false + */ + public function insert(\XoopsObject $module) + { + } + /** + * Delete a module from the database + * + * @param XoopsObject|XoopsModule $module a XoopsModule object + * + * @return bool true on success, otherwise false + */ + public function delete(\XoopsObject $module) + { + } + /** + * Load some modules + * + * @param CriteriaElement|CriteriaCompo $criteria {@link CriteriaElement} + * @param boolean $id_as_key Use the ID as key into the array + * @return array + */ + public function getObjects(?\CriteriaElement $criteria = \null, $id_as_key = \false) + { + } + /** + * Count some modules + * + * @param CriteriaElement|CriteriaCompo $criteria {@link CriteriaElement} + * @return int + */ + public function getCount(?\CriteriaElement $criteria = \null) + { + } + /** + * returns an array of module names + * + * @param CriteriaElement|null $criteria + * @param boolean $dirname_as_key if true, array keys will be module directory names + * if false, array keys will be module id + * @return array + */ + public function getList(?\CriteriaElement $criteria = \null, $dirname_as_key = \false) + { + } +} +} + +namespace { +/** + * Connection to a MySQL database using MySQLi extension + */ +abstract class XoopsMySQLDatabase extends \XoopsDatabase +{ + /** + * Strict guard is active in dev or when XOOPS debug mode is on. + */ + private function isStrict(): bool + { + } + /** + * Database connection + * + * @var XoopsDatabase|mysqli + */ + public $conn; + /** + * connect to the database + * + * @param bool $selectdb select the database now? + * @return bool successful? + */ + public function connect($selectdb = \true) + { + } + /** + * generate an ID for a new row + * + * This is for compatibility only. Will always return 0, because MySQL supports + * autoincrement for primary keys. + * + * @param string $sequence name of the sequence from which to get the next ID + * @return int always 0, because mysql has support for autoincrement + */ + public function genId($sequence) + { + } + /** + * Report a call that handed us something other than a result set. + * + * The methods below receive whatever the caller got back from query(). On a failed + * query that is false, and a mis-wired caller can pass the SQL string itself. PHP 8 + * turns both into an uncaught TypeError inside mysqli, so one bad call from one + * module takes the whole page down with a blank screen -- which is how this surfaced + * on xoops.org, from modules/core/brokenfile.php passing a string to getRowsNum(). + * + * The '@' on the mysqli calls never covered that: it suppresses diagnostics, not + * thrown errors. Each caller now returns the failure value it already documents, + * which keeps the page alive and hands the caller the answer it is written to expect. + * + * Reported rather than swallowed, through the same channel this class already uses + * for caller misuse, so the module bug stays visible instead of quietly becoming an + * empty result. + * + * @param string $method calling method + * @param mixed $result the value handed to us + * + * @return void + */ + protected function reportInvalidResult($method, $result) + { + } + /** + * Get a result row as an enumerated array + * + * @param \mysqli_result $result + * + * @return array|false false on end of data + */ + public function fetchRow($result) + { + } + /** + * Fetch a result row as an associative array + * + * @param \mysqli_result $result + * + * @return array|false false on end of data + */ + public function fetchArray($result) + { + } + /** + * Fetch a result row as an associative array + * + * @param \mysqli_result $result + * + * @return array|false false on end of data + */ + public function fetchBoth($result) + { + } + /** + * XoopsMySQLDatabase::fetchObject() + * + * @param \mysqli_result $result + * @return stdClass|false false on end of data + */ + public function fetchObject($result) + { + } + /** + * Get the ID generated from the previous INSERT operation + * + * @return int|string + */ + public function getInsertId() + { + } + /** + * Get number of rows in result + * + * @param \mysqli_result $result + * + * @return int + */ + public function getRowsNum($result) + { + } + /** + * Get number of affected rows + * + * @return int + */ + public function getAffectedRows() + { + } + /** + * Close MySQL connection + * + * @return void + */ + public function close() + { + } + /** + * will free all memory associated with the result identifier result. + * + * @param \mysqli_result $result result + * + * @return void + */ + public function freeRecordSet($result) + { + } + /** + * Returns the text of the error message from previous MySQL operation + * + * @return string Returns the error text from the last MySQL function, or '' (the empty string) if no error occurred. + */ + public function error() + { + } + /** + * Returns the numerical value of the error message from previous MySQL operation + * + * @return int Returns the error number from the last MySQL function, or 0 (zero) if no error occurred. + */ + public function errno() + { + } + /** + * Returns escaped string text with single quotes around it to be safely stored in database + * + * @param string $str unescaped string text + * @return string escaped string text with single quotes around + * @deprecated : delegate to exec(). + */ + public function quoteString($str) + { + } + /** + * Quotes a string for use in a query. + * + * @param string $string string to quote/escape for use in query + * + * @return string + */ + public function quote($string) + { + } + /** + * Escapes a string for use in a query. Does not add surrounding quotes. + * + * @param string $string string to escape + * + * @return string + */ + public function escape($string) + { + } + /** + * perform a query on the database + * + * @param string $sql a valid MySQL query + * @param int $limit number of records to return + * @param int $start offset of first record to return + * @return mysqli_result|bool query result or FALSE if successful + * or TRUE if successful and no result + */ + public function queryF($sql, $limit = 0, $start = 0) + { + } + /** + * perform a query + * + * This method is empty and does nothing! It should therefore only be + * used if nothing is exactly what you want done! ;-) + * + * @param string $sql a valid MySQL query + * @param int|null $limit number of records to return + * @param int|null $start offset of first record to return + * + * @return \mysqli_result|bool false on failure; true only if a write slipped through (BC) + */ + public function query(string $sql, ?int $limit = \null, ?int $start = \null) + { + } + /** + * perform queries from SQL dump file in a batch + * + * @param string $file file path to an SQL dump file + * @return bool FALSE if failed reading SQL file or TRUE if the file has been read and queries executed + */ + public function queryFromFile($file) + { + } + /** + * Get field name + * + * @param \mysqli_result $result query result + * @param int $offset numerical field index + * + * @return string + */ + public function getFieldName($result, $offset) + { + } + /** + * Get field type + * + * @param \mysqli_result $result query result + * @param int $offset numerical field index + * + * @return string + */ + public function getFieldType($result, $offset) + { + } + /** + * Get number of fields in result + * + * @param \mysqli_result $result query result + * + * @return int + */ + public function getFieldsNum($result) + { + } + /** + * getServerVersion get version of the mysql server + * + * @return string + */ + public function getServerVersion() + { + } + /** + * Test the passed result to determine if it is a valid result set + * + * @param mixed $result value to test + * + * @return bool true if $result is a database result set, otherwise false + */ + public function isResultSet($result) + { + } + /** + * Perform a mutating statement (INSERT/UPDATE/DELETE/DDL). + * + * @param string $sql + * @return bool + * @throws \mysqli_sql_exception If a MySQLi error occurs and MySQLi is configured to throw exceptions. + */ + public function exec(string $sql): bool + { + } +} +} + +namespace { +/** + * Base class for all objects in the Xoops kernel (and beyond) + */ +class XoopsObject +{ + /** + * Ids of the datatypes deprecated in XOOPS 2.7.3 (XOBJ_DTYPE_UNICODE_* = 16-21), + * scheduled for removal in 4.0. Kept here as literal ids, in one place, so this + * reference site does not itself trip the deprecation check; see the deprecation + * doc block near the XOBJ_DTYPE_UNICODE_* defines in this file. + * + * @var int[] + */ + private const DEPRECATED_UNICODE_DATATYPES = [16, 17, 18, 19, 20, 21]; + /** + * holds all variables(properties) of an object + * + * @var array + * @access protected + */ + public $vars = []; + /** + * variables cleaned for store in DB + * + * @var array + * @access protected + */ + public $cleanVars = []; + /** + * is it a newly created object? + * + * @var bool + * @access private + */ + public $_isNew = \false; + /** + * has any of the values been modified? + * + * @var bool + * @access private + */ + public $_isDirty = \false; + /** + * errors + * + * @var array + * @access private + */ + public $_errors = []; + /** + * additional filters registered dynamically by a child class object + * + * @access private + */ + public $_filters = []; + /** + * constructor + * + * normally, this is called from child classes only + * + * @access public + */ + public function __construct() + { + } + /** + * PHP 4 style constructor compatibility shim + * @deprecated all callers should be using parent::__construct() + */ + public function XoopsObject() + { + } + /** + * *#@+ + * used for new/clone objects + * + * @access public + */ + public function setNew() + { + } + public function unsetNew() + { + } + /** + * @return bool + */ + public function isNew() + { + } + /** + * *#@+ + * mark modified objects as dirty + * + * used for modified objects only + * + * @access public + */ + public function setDirty() + { + } + public function unsetDirty() + { + } + /** + * @return bool + */ + public function isDirty() + { + } + /** + * initialize variables for the object + * + * YOU SHOULD NOT USE THE $enumeration PARAMETER + * + * @access public + * + * @param string $key + * @param int $data_type set to one of XOBJ_DTYPE_XXX constants (set to XOBJ_DTYPE_OTHER if no data type checking nor text sanitizing is required) + * @param mixed $value + * @param bool $required require html form input? + * @param int|null $maxlength for XOBJ_DTYPE_TXTBOX type only + * @param string $options + * @param string $enumerations + * + * @return void + */ + public function initVar($key, $data_type, $value = \null, $required = \false, $maxlength = \null, $options = '', $enumerations = '') + { + } + /** + * assign a value to a variable + * + * @access public + * @param string $key name of the variable to assign + * @param mixed $value value to assign + */ + public function assignVar($key, $value) + { + } + /** + * assign values to multiple variables in a batch + * + * @access private + * @param array $var_arr associative array of values to assign + */ + public function assignVars($var_arr) + { + } + /** + * assign a value to a variable + * + * @access public + * @param string $key name of the variable to assign + * @param mixed $value value to assign + * @param bool $not_gpc + */ + public function setVar($key, $value, $not_gpc = \false) + { + } + /** + * assign values to multiple variables in a batch + * + * @access private + * @param array $var_arr associative array of values to assign + * @param bool $not_gpc + */ + public function setVars($var_arr, $not_gpc = \false) + { + } + /** + * unset variable(s) for the object + * + * @access public + * + * @param mixed $var + * + * @return bool + */ + public function destroyVars($var) + { + } + /** + * @param mixed $var + * @return bool + * @deprecated use destroyVars() instead, destoryVars() will be removed in the next major release + */ + public function destoryVars($var) + { + } + /** + * Assign values to multiple variables in a batch + * + * Meant for a CGI context: + * - prefixed CGI args are considered save + * - avoids polluting of namespace with CGI args + * + * @param array $var_arr associative array of values to assign + * @param string $pref prefix (only keys starting with the prefix will be set) + * @param bool $not_gpc + * + * @return void + * + * @deprecated This method will be removed in the next major XOOPS version + */ + public function setFormVars($var_arr = \null, $pref = 'xo_', $not_gpc = \false) + { + } + /** + * returns all variables for the object + * + * @access public + * @return array associative array of key->value pairs + */ + public function &getVars() + { + } + /** + * Returns the values of the specified variables + * + * @param mixed $keys An array containing the names of the keys to retrieve, or null to get all of them + * @param string $format Format to use (see getVar) + * @param int $maxDepth Maximum level of recursion to use if some vars are objects themselves + * @return array associative array of key->value pairs + */ + public function getValues($keys = \null, $format = 's', $maxDepth = 1) + { + } + /** + * returns a specific variable for the object in a proper format + * + * YOU SHOULD NOT USE ANY OF THE UNICODE TYPES, THEY WILL BE REMOVED + * + * @access public + * @param string $key key of the object's variable to be returned + * @param string|null $format format to use for the output + * @return mixed formatted value of the variable + */ + public function getVar($key, ?string $format = \null) + { + } + /** + * clean values of all variables of the object for storage. + * also add slashes wherever needed + * + * YOU SHOULD NOT USE ANY OF THE UNICODE TYPES, THEY WILL BE REMOVED + * + * @return bool true if successful + * @access public + */ + public function cleanVars() + { + } + /** + * dynamically register additional filter for the object + * + * @param string $filtername name of the filter + * + * @deprecated \XoopsObject::registerFilter is deprecated since XOOPS 2.5.8 and will be removed in the next major release + */ + public function registerFilter($filtername) + { + } + /** + * load all additional filters that have been registered to the object + * + * @access private + */ + public function _loadFilters() + { + } + /** + * load all local filters for the object + * + * Filter distribution: + * In each module folder there is a folder "filter" containing filter files with, + * filename: [name_of_target_class][.][function/action_name][.php]; + * function name: [dirname][_][name_of_target_class][_][function/action_name]; + * parameter: the target object + * + * @param string $method function or action name + * + * @deprecated \XoopsObject::loadFilters is deprecated since XOOPS 2.5.8 and will be removed in the next major release + */ + public function loadFilters($method) + { + } + /** + * create a clone(copy) of the current object + * + * @access public + * @return object clone + */ + public function xoopsClone() + { + } + /** + * Adjust a newly cloned object + */ + public function __clone() + { + } + /** + * add an error + * + * @param array|string $err_str + * @internal param string $value error to add + * @access public + */ + public function setErrors($err_str) + { + } + /** + * return the errors for this object as an array + * + * @return array an array of errors + * @access public + */ + public function getErrors() + { + } + /** + * return the errors for this object as html + * + * @return string html listing the errors + * @access public + */ + public function getHtmlErrors() + { + } + /** + * Returns an array representation of the object + * + * Deprecated, use getValues() directly + * + * @return array + */ + public function toArray() + { + } +} +} + +namespace { +/** + * XOOPS object handler class. + * This class is an abstract class of handler classes that are responsible for providing + * data access mechanisms to the data source of its corresponding data objects + * + * @package kernel + * @abstract + * @author Kazumi Ono + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + */ +class XoopsObjectHandler +{ + /** + * XoopsDatabase holds referenced to {@link XoopsDatabase} class object + * + * @var XoopsDatabase + */ + public $db; + /** + * called from child classes only + * + * @param XoopsDatabase $db reference to the {@link XoopsDatabase} object + * @access protected + */ + public function __construct(\XoopsDatabase $db) + { + } + /** + * Build a safe ORDER BY column list from a (possibly multi-column) sort string. + * + * Splits $sort on commas, keeps only tokens whose (optionally table-qualified) + * column name is in $allowedColumns, and gives each clause a direction: its own + * ASC/DESC when present, otherwise the sanitized $order. The emitted clause uses + * $columnPrefix . column — any qualifier on the INPUT is dropped and replaced with + * the caller's canonical prefix, so an unknown alias (e.g. "bogus.com_id") cannot + * reach the SQL, and a joined query can force its own alias (e.g. "i.") to keep + * columns shared by two tables unambiguous. Single-table callers pass '' (the + * default) for bare columns. This blocks ORDER BY injection, supports multi-column + * sorts, and — because each clause already carries its direction — must be used + * WITHOUT appending the criteria order again. + * + * @param string $sort raw sort string, e.g. "image_weight ASC, image_id" + * @param string $order default direction (ASC/DESC) for clauses with none + * @param string[] $allowedColumns lowercase column names permitted for this table + * @param string $defaultColumn column used when no valid clause remains + * @param string $columnPrefix canonical qualifier prepended to every column + * (e.g. "i."); '' emits bare columns. Only '' + * or a single "alias." is honoured; anything + * else is neutralised to ''. + * @return string a safe ORDER BY column list including directions + */ + public static function buildOrderBy($sort, $order, array $allowedColumns, $defaultColumn, $columnPrefix = '') + { + } + /** + * PHP 4 style constructor compatibility shim + * + * @param XoopsDatabase $db database object + * @deprecated all callers should be using parent::__construct() + */ + public function XoopsObjectHandler($db) + { + } + /** + * creates a new object + * + * @abstract + * @return XoopsObject + */ + public function create() + { + } + /** + * gets a value object + * + * @param int $id + * @abstract + * @return XoopsObject + */ + public function get($id) + { + } + /** + * insert/update object + * + * @param XoopsObject $object + * @abstract + */ + public function insert(\XoopsObject $object) + { + } + /** + * delete object from database + * + * @param XoopsObject $object + * @abstract + */ + public function delete(\XoopsObject $object) + { + } +} +} + +namespace { +/** + * A tree structures with {@link XoopsObject}s as nodes + * + * @package kernel + * @subpackage core + * @author Kazumi Ono + */ +class XoopsObjectTree +{ + /** + * @access private + */ + protected $parentId; + protected $myId; + protected $rootId; + protected $tree = []; + protected $objects; + /** + * Constructor + * + * @param array $objectArr Array of {@link XoopsObject}s + * @param string $myId field name of object ID + * @param string $parentId field name of parent object ID + * @param string|null $rootId field name of root object ID + */ + public function __construct($objectArr, $myId, $parentId, ?string $rootId = \null) + { + } + /** + * Initialize the object + * + * @access private + */ + protected function initialize() + { + } + /** + * Get the tree + * + * @return array Associative array comprising the tree + */ + public function &getTree() + { + } + /** + * returns an object from the tree specified by its id + * + * @param string $key ID of the object to retrieve + * @return object Object within the tree + */ + public function &getByKey($key) + { + } + /** + * returns an array of all the first child object of an object specified by its id + * + * @param string $key ID of the parent object + * @return array Array of children of the parent + */ + public function getFirstChild($key) + { + } + /** + * returns an array of all child objects of an object specified by its id + * + * @param string $key ID of the parent + * @param array $ret (Empty when called from client) Array of children from previous recursions. + * @return array Array of child nodes. + */ + public function getAllChild($key, $ret = []) + { + } + /** + * returns an array of all parent objects. + * the key of returned array represents how many levels up from the specified object + * + * @param string $key ID of the child object + * @param array $ret (empty when called from outside) Result from previous recursions + * @param int $upLevel (empty when called from outside) level of recursion + * @return array Array of parent nodes. + */ + public function getAllParent($key, $ret = [], $upLevel = 1) + { + } + /** + * Make options for a select box from + * + * @param string $fieldName Name of the member variable from the + * node objects that should be used as the title for the options. + * @param string $selected Value to display as selected + * @param int $key ID of the object to display as the root of select options + * @param string $ret (reference to a string when called from outside) Result from previous recursions + * @param string $prefix_orig String to indent items at deeper levels + * @param string $prefix_curr String to indent the current item + * + * @return void + * @deprecated since 2.5.9, please use makeSelectElement() functionality + */ + protected function makeSelBoxOptions($fieldName, $selected, $key, &$ret, $prefix_orig, $prefix_curr = '') + { + } + /** + * Make a select box with options from the tree + * + * @param string $name Name of the select box + * @param string $fieldName Name of the member variable from the + * node objects that should be used as the title for the options. + * @param string $prefix String to indent deeper levels + * @param string $selected Value to display as selected + * @param bool $addEmptyOption Set TRUE to add an empty option with value "0" at the top of the hierarchy + * @param integer $key ID of the object to display as the root of select options + * @param string $extra + * @return string HTML select box + * + * @deprecated since 2.5.9, please use makeSelectElement() + */ + public function makeSelBox($name, $fieldName, $prefix = '-', $selected = '', $addEmptyOption = \false, $key = 0, $extra = '') + { + } + /** + * Make a select box with options from the tree + * + * @param string $name Name of the select box + * @param string $fieldName Name of the member variable from the + * node objects that should be used as the title for the options. + * @param string $prefix String to indent deeper levels + * @param string $selected Value to display as selected + * @param bool $addEmptyOption Set TRUE to add an empty option with value "0" at the top of the hierarchy + * @param integer $key ID of the object to display as the root of select options + * @param string $extra extra content to add to the element + * @param string $caption optional caption for form element + * + * @return XoopsFormSelect form element + */ + public function makeSelectElement($name, $fieldName, $prefix = '-', $selected = '', $addEmptyOption = \false, $key = 0, $extra = '', $caption = '') + { + } + /** + * Make options for a select box from + * + * @param XoopsFormSelect $element form element to receive tree values as options + * @param string $fieldName Name of the member variable from the node objects that + * should be used as the title for the options. + * @param int $key ID of the object to display as the root of select options + * @param string $prefix_orig String to indent items at deeper levels + * @param string $prefix_curr String to indent the current item + * + * @return void + * @access private + */ + protected function addSelectOptions($element, $fieldName, $key, $prefix_orig, $prefix_curr = '') + { + } + /** + * Magic __get method + * + * Some modules did not respect the leading underscore is private convention and broke + * when code was modernized. This will keep them running for now. + * + * @param string $name unknown variable name requested + * currently, only '_tree' is supported + * + * @return mixed value + */ + public function __get($name) + { + } +} +} + +namespace { +/** + * Persistable Object Handler class. + * + * @author Taiwen Jiang + * @author Jan Keller Pedersen + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + * @package Kernel + */ +class XoopsPersistableObjectHandler extends \XoopsObjectHandler +{ + //PHP 8.2 Dynamic properties deprecated + public $table_link; + /** + * holds reference to custom extended object handler + * + * var object + * + * @access private + */ + /** + * static protected + */ + public $handler; + /** + * holds reference to predefined extended object handlers: read, stats, joint, write, sync + * + * The handlers hold methods for different purposes, which could be all put together inside the current class. + * However, load codes only if they are necessary, thus they are now split out. + * + * var array of objects + * + * @access private + */ + /** + * static protected + */ + public $handlers = ['read' => \null, 'stats' => \null, 'joint' => \null, 'write' => \null, 'sync' => \null]; + /** + * Information about the class, the handler is managing + * + * @var string + */ + public $table; + /** + * @var string + */ + public $keyName; + /** + * @var string + */ + public $className; + /** + * @var string + */ + public $identifierName; + /** + * @var string + */ + public $field_link; + /** + * @var string + */ + public $field_object; + /** + * Constructor + * + * @param XoopsDatabase|null $db database connection + * @param string $table Name of database table + * @param string $className Name of the XoopsObject class this handler manages + * @param string $keyName Name of the property holding the key + * @param string $identifierName Name of the property holding an identifier + * name (title, name ...), used on getList() + */ + public function __construct(?\XoopsDatabase $db = \null, $table = '', $className = '', $keyName = '', $identifierName = '') + { + } + /** + * PHP 4 style constructor compatibility shim + * + * @param XoopsDatabase|null $db database connection + * @param string $table Name of database table + * @param string $className Name of the XoopsObject class this handler manages + * @param string $keyName Name of the property holding the key + * @param string $identifierName Name of the property holding an identifier + * name (title, name ...), used on getList() + * + * @deprecated all callers should be using parent::__construct() + */ + public function XoopsPersistableObjectHandler(?\XoopsDatabase $db = \null, $table = '', $className = '', $keyName = '', $identifierName = '') + { + } + /** + * Set custom handler + * + * @access protected + * @param mixed $handler + * @param mixed $args + * @param string|null $path path to class + * @internal param object $handler + * @internal param mixed $args + * @return object of handler + */ + public function setHandler($handler = \null, $args = \null, $path = \null) + { + } + /** + * Load predefined handler + * + * @access protected + * @param string $name handler name + * @param mixed $args args + * @return XoopsModelAbstract of handler {@link XoopsModelAbstract} + */ + public function loadHandler($name, $args = \null) + { + } + /** + * Magic method for overloading of delegation + * + * To be enabled in XOOPS 3.0 with PHP 5 + * + * @access protected + * @param string $name method name + * @param array $args arguments + * @return mixed + */ + public function __call($name, $args) + { + } + /** + * *#@+ + * Methods of native handler {@link XoopsPersistableObjectHandler} + */ + /** + * create a new object + * + * @param bool $isNew Flag the new objects as new + * @return XoopsObject {@link XoopsObject} + */ + public function create($isNew = \true) + { + } + /** + * Load a {@link XoopsObject} object from the database + * + * @access protected + * @param mixed $id ID + * @param array|null $fields fields to fetch + * @return XoopsObject|null {@link XoopsObject} + */ + public function get($id = \null, $fields = \null) + { + } + /** + * *#@- + */ + /** + * *#@+ + * Methods of write handler {@link XoopsObjectWrite} + */ + /** + * insert an object into the database + * + * @param XoopsObject $object {@link XoopsObject} reference to object + * @param bool $force flag to force the query execution despite security settings + * @return mixed object ID + */ + public function insert(\XoopsObject $object, $force = \true) + { + } + /** + * delete an object from the database + * + * @param XoopsObject $object {@link XoopsObject} reference to the object to delete + * @param bool $force + * @return bool FALSE if failed. + */ + public function delete(\XoopsObject $object, $force = \false) + { + } + /** + * delete all objects matching the conditions + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} with conditions to meet + * @param bool $force force to delete + * @param bool $asObject delete in object way: instantiate all objects and delete one by one + * @return bool|int + */ + public function deleteAll(?\CriteriaElement $criteria = \null, $force = \true, $asObject = \false) + { + } + /** + * Change a field for objects with a certain criteria + * + * @param string $fieldname Name of the field + * @param mixed $fieldvalue Value to write + * @param \CriteriaElement|null $criteria {@link CriteriaElement} + * @param bool $force force to query + * @return bool + */ + public function updateAll($fieldname, $fieldvalue, ?\CriteriaElement $criteria = \null, $force = \false) + { + } + /** + * *#@- + */ + /** + * *#@+ + * Methods of read handler {@link XoopsObjectRead} + */ + /** + * Retrieve objects from the database + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} conditions to be met + * @param bool $id_as_key use the ID as key for the array + * @param bool $as_object return an array of objects + * @return array + */ + public function &getObjects(?\CriteriaElement $criteria = \null, $id_as_key = \false, $as_object = \true) + { + } + /** + * get all objects matching a condition + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @param array|null $fields variables to fetch + * @param bool $asObject flag indicating as object, otherwise as array + * @param bool $id_as_key use the ID as key for the array + * @return array of objects/array {@link XoopsObject} + */ + public function &getAll(?\CriteriaElement $criteria = \null, $fields = \null, $asObject = \true, $id_as_key = \true) + { + } + /** + * Retrieve a list of objects data + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} conditions to be met + * @param int $limit Max number of objects to fetch + * @param int $start Which record to start at + * @return array + */ + public function getList(?\CriteriaElement $criteria = \null, $limit = 0, $start = 0) + { + } + /** + * get IDs of objects matching a condition + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @return array of object IDs + */ + public function &getIds(?\CriteriaElement $criteria = \null) + { + } + /** + * get a limited list of objects matching a condition + * + * {@link CriteriaCompo} + * + * @param int $limit Max number of objects to fetch + * @param int $start Which record to start at + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @param array|null $fields variables to fetch + * @param bool $asObject flag indicating as object, otherwise as array + * @return array of objects {@link XoopsObject} + */ + public function &getByLimit($limit = 0, $start = 0, ?\CriteriaElement $criteria = \null, $fields = \null, $asObject = \true) + { + } + /** + * *#@- + */ + /** + * *#@+ + * Methods of stats handler {@link XoopsObjectStats} + */ + /** + * count objects matching a condition + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @return int count of objects + */ + public function getCount(?\CriteriaElement $criteria = \null) + { + } + /** + * Get counts of objects matching a condition + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @return array of counts + */ + public function getCounts(?\CriteriaElement $criteria = \null) + { + } + /** + * *#@- + */ + /** + * *#@+ + * Methods of joint handler {@link XoopsObjectJoint} + */ + /** + * get a list of objects matching a condition joint with another related object + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @param array|null $fields variables to fetch + * @param bool $asObject flag indicating as object, otherwise as array + * @param string|null $field_link field of linked object for JOIN + * @param string|null $field_object field of current object for JOIN + * @return array of objects {@link XoopsObject} + */ + public function getByLink(?\CriteriaElement $criteria = \null, $fields = \null, $asObject = \true, $field_link = \null, $field_object = \null) + { + } + /** + * Count of objects matching a condition + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @return int count of objects + */ + public function getCountByLink(?\CriteriaElement $criteria = \null) + { + } + /** + * array of count of objects matching a condition of, groupby linked object keyname + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @return int count of objects + */ + public function getCountsByLink(?\CriteriaElement $criteria = \null) + { + } + /** + * update objects matching a condition against linked objects + * + * @param array $data array of key => value + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @return int count of objects + */ + public function updateByLink($data, ?\CriteriaElement $criteria = \null) + { + } + /** + * Delete objects matching a condition against linked objects + * + * @param \CriteriaElement|null $criteria {@link CriteriaElement} to match + * @return int|null count of objects + */ + public function deleteByLink(?\CriteriaElement $criteria = \null) + { + } + /** + * *#@- + */ + /** + * *#@+ + * Methods of sync handler {@link XoopsObjectSync} + */ + /** + * Clean orphan objects against linked objects + * + * @param string $table_link table of linked object for JOIN + * @param string $field_link field of linked object for JOIN + * @param string $field_object field of current object for JOIN + * @return bool true on success + */ + public function cleanOrphan($table_link = '', $field_link = '', $field_object = '') + { + } + /** + * Synchronizing objects + * + * @return bool true on success + */ + public function synchronization() + { + } + /** + * *#@- + */ + /**#@+ + * @deprecated + * @param $result + * @param bool $id_as_key + * @param bool $as_object + * @return bool + */ + public function convertResultSet($result, $id_as_key = \false, $as_object = \true) + { + } +} +} + +namespace { +/** + * Class XoopsSecurity + */ +class XoopsSecurity +{ + public $errors = []; + /** + * Check if there is a valid token in $_REQUEST[$name . '_REQUEST'] - can be expanded for more wide use, later (Mith) + * + * @param bool $clearIfValid whether to clear the token after validation + * @param string|false $token token to validate + * @param string $name name of session variable + * + * @return bool + */ + public function check($clearIfValid = \true, $token = \false, $name = 'XOOPS_TOKEN') + { + } + /** + * Create a token in the user's session + * + * @param int|string $timeout time in seconds the token should be valid + * @param string $name name of session variable + * + * @return string token value + */ + public function createToken($timeout = 0, $name = 'XOOPS_TOKEN') + { + } + /** + * Check if a token is valid. If no token is specified, $_REQUEST[$name . '_REQUEST'] is checked + * + * @param string|false $token token to validate + * @param bool $clearIfValid whether to clear the token value if valid + * @param string $name session name to validate + * + * @return bool + */ + public function validateToken($token = \false, $clearIfValid = \true, $name = 'XOOPS_TOKEN') + { + } + /** + * Clear all token values from user's session + * + * @param string $name session name + * + * @return void + */ + public function clearTokens($name = 'XOOPS_TOKEN') + { + } + /** + * Check whether a token value is expired or not + * + * @param string $token token + * + * @return bool + */ + public function filterToken($token) + { + } + /** + * Perform garbage collection, clearing expired tokens + * + * @param string $name session name + * + * @return void + */ + public function garbageCollection($name = 'XOOPS_TOKEN') + { + } + /** + * Check the user agent's HTTP REFERER against XOOPS_URL + * + * @param int $docheck 0 to not check the referer (used with XML-RPC), 1 to actively check it + * + * @return bool + */ + public function checkReferer($docheck = 1) + { + } + /** + * Check superglobals for contamination + * + * @return void + **/ + public function checkSuperglobals() + { + } + /** + * Check if visitor's IP address is banned + * Should be changed to return bool and let the action be up to the calling script + * + * @return void + */ + public function checkBadips() + { + } + /** + * Get the HTML code for a XoopsFormHiddenToken object - used in forms that do not use XoopsForm elements + * + * @param string $name session token name + * + * @return string + */ + public function getTokenHTML($name = 'XOOPS_TOKEN') + { + } + /** + * Add an error + * + * @param string $error message + * + * @return void + */ + public function setErrors($error) + { + } + /** + * Get generated errors + * + * @param bool $ashtml Format using HTML? + * + * @return array|string Array of array messages OR HTML string + */ + public function &getErrors($ashtml = \false) + { + } +} +} + +namespace { +/** + * Form that will output as a theme-enabled HTML table + * + * Also adds JavaScript to validate required fields + */ +class XoopsThemeForm extends \XoopsForm +{ + /** + * Insert an empty row in the table to serve as a separator. + * + * @param string $extra HTML to be displayed in the empty row. + * @param string $class CSS class name for tag + */ + public function insertBreak($extra = '', $class = '') + { + } + /** + * create HTML to output the form as a theme-enabled table with validation. + * + * YOU SHOULD AVOID THE USE THE FOLLOWING Nocolspan METHOD, IT WILL BE REMOVED + * + * To use the noColspan simply use the following example: + * + * $colspan = new XoopsFormDhtmlTextArea( '', 'key', $value, '100%', '100%' ); + * $colspan->setNocolspan(); + * $form->addElement( $colspan ); + * + * @return string + */ + public function render() + { + } +} +} + +namespace { +/** + * Template engine + * + * @package kernel + * @subpackage core + * @author Kazumi Ono + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + */ +class XoopsTpl extends \Smarty\Smarty +{ + /** @var xos_opal_Theme */ + public $currentTheme; + /** + * XoopsTpl constructor. + */ + public function __construct() + { + } + /** + * Renders output from template data + * + * @param string $tplSource The template to render + * @param bool $display If rendered text should be output or returned + * @param mixed $vars + * + * @return string Rendered output if $display was false + */ + public function fetchFromData($tplSource, $display = \false, $vars = \null) + { + } + /** + * XoopsTpl::touch + * + * @param mixed $resourceName + * @return bool + */ + public function xoopsTouch($resourceName) + { + } + /** + * returns an auto_id for auto-file-functions + * + * @param string $cache_id + * @param string $compile_id + * @return string |null + */ + public function _get_auto_id($cache_id = \null, $compile_id = \null) + { + } + /** + * XoopsTpl::setCompileId() + * + * @param mixed $module_dirname + * @param mixed $theme_set + * @param mixed $template_set + * @return void + */ + public function setCompileId($module_dirname = \null, $theme_set = \null, $template_set = \null) + { + } + /** + * XoopsTpl::clearCache() + * + * @param mixed $module_dirname + * @param mixed $theme_set + * @param mixed $template_set + * @return bool + */ + public function xoopsClearCache($module_dirname = \null, $theme_set = \null, $template_set = \null) + { + } + /** + * + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @param $dirname + */ + public function xoops_setTemplateDir($dirname) + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @return string + */ + public function xoops_getTemplateDir() + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @param bool $flag + */ + public function xoops_setDebugging($flag = \false) + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @param int $num + */ + public function xoops_setCaching($num = 0) + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @param $dirname + */ + public function xoops_setCompileDir($dirname) + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @param $dirname + */ + public function xoops_setCacheDir($dirname) + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @return bool + */ + public function xoops_canUpdateFromFile() + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @param $data + * + * @return string + */ + public function xoops_fetchFromData($data) + { + } + /** + * @deprecated DO NOT USE THESE METHODS, ACCESS THE CORRESPONDING PROPERTIES INSTEAD + * @param int $num + */ + public function xoops_setCacheTime($num = 0) + { + } + /** + * Smarty 5 compatibility bridge for released XOOPS callers. + * + * @param string $tpl_var + * @param mixed $value + * @param bool $nocache + * + * @return $this + */ + public function assignByRef($tpl_var, &$value, $nocache = \false) + { + } + /** + * Smarty 5 compatibility bridge for released XOOPS callers. + * + * @param string $tpl_var + * @param mixed $value + * @param bool $merge + * @param bool $nocache + * + * @return $this + */ + public function appendByRef($tpl_var, &$value, $merge = \false, $nocache = \false) + { + } + /** + * Smarty 5 normalizes parsed tag names to lowercase. Preserve released + * camel-case XOOPS tags by registering an equivalent lowercase key. + */ + private function registerLowercasePluginAliases(): void + { + } + /** + * deprecated assign_by_ref + * + * @param string $tpl_var the template variable name + * @param mixed &$value the referenced value to assign + * @deprecated + */ + public function assign_by_ref($tpl_var, &$value) + { + } + /** + * deprecated append_by_ref + * + * @param string $tpl_var the template variable name + * @param mixed &$value the referenced value to append + * @param boolean $merge flag if array elements shall be merged + * @deprecated + */ + public function append_by_ref($tpl_var, &$value, $merge = \false) + { + } + /** + * deprecated clear_assign + * + * @param string $tpl_var the template variable to clear + * @deprecated + */ + public function clear_assign($tpl_var) + { + } + /** + * deprecated register_function + * + * @param string $function the name of the template function + * @param string $function_impl the name of the PHP function to register + * @param bool $cacheable + * @param mixed $cache_attrs + * + * @throws \SmartyException + * @deprecated + */ + public function register_function($function, $function_impl, $cacheable = \true, $cache_attrs = \null) + { + } + /** + * deprecated unregister_function + * + * @param string $function name of template function + * @deprecated + */ + public function unregister_function($function) + { + } + /** + * deprecated register_object + * + * @param string $object name of template object + * @param object $object_impl the referenced PHP object to register + * @param array $allowed list of allowed methods (empty = all) + * @param boolean $smarty_args smarty argument format, else traditional + * @param array $block_methods list of methods that are block format + * + * @throws SmartyException + * @internal param array $block_functs list of methods that are block format + * @deprecated + */ + public function register_object($object, $object_impl, $allowed = [], $smarty_args = \true, $block_methods = []) + { + } + /** + * deprecated unregister_object + * + * @param string $object name of template object + * @deprecated + */ + public function unregister_object($object) + { + } + /** + * deprecated register_block + * + * @param string $block name of template block + * @param string $block_impl PHP function to register + * @param bool $cacheable + * @param mixed $cache_attrs + * + * @throws \SmartyException + * @deprecated + */ + public function register_block($block, $block_impl, $cacheable = \true, $cache_attrs = \null) + { + } + /** + * deprecated unregister_block + * + * @param string $block name of template function + * @deprecated + */ + public function unregister_block($block) + { + } + /** + * deprecated register_compiler_function + * + * @param string $function name of template function + * @param string $function_impl name of PHP function to register + * @param bool $cacheable + * + * @throws \SmartyException + * @deprecated + */ + public function register_compiler_function($function, $function_impl, $cacheable = \true) + { + } + /** + * deprecated unregister_compiler_function + * + * @param string $function name of template function + * @deprecated + */ + public function unregister_compiler_function($function) + { + } + /** + * deprecated register_modifier + * + * @param string $modifier name of template modifier + * @param string $modifier_impl name of PHP function to register + * + * @throws \SmartyException + * @deprecated + */ + public function register_modifier($modifier, $modifier_impl) + { + } + /** + * deprecated unregister_modifier + * + * @param string $modifier name of template modifier + * @deprecated + */ + public function unregister_modifier($modifier) + { + } + /** + * deprecated register_resource + * + * @param string $type name of resource + * @param array $functions array of functions to handle resource + * @deprecated + */ + public function register_resource($type, $functions) + { + } + /** + * deprecated unregister_resource + * + * @param string $type name of resource + * @deprecated + */ + public function unregister_resource($type) + { + } + /** + * deprecated register_prefilter + * + * @param callable $function + * + * @throws \SmartyException + * @deprecated + */ + public function register_prefilter($function) + { + } + /** + * deprecated unregister_prefilter + * + * @param callable $function + * @deprecated + */ + public function unregister_prefilter($function) + { + } + /** + * deprecated register_postfilter + * + * @param callable $function + * + * @throws \SmartyException + * @deprecated + */ + public function register_postfilter($function) + { + } + /** + * deprecated unregister_postfilter + * + * @param callable $function + * @deprecated + */ + public function unregister_postfilter($function) + { + } + /** + * deprecated register_outputfilter + * + * @param callable $function + * + * @throws \SmartyException + * @deprecated + */ + public function register_outputfilter($function) + { + } + /** + * deprecated unregister_outputfilter + * + * @param callable $function + * @deprecated + */ + public function unregister_outputfilter($function) + { + } + /** + * deprecated load_filter + * + * @param string $type filter type + * @param string $name filter name + * + * @throws \SmartyException + * @deprecated + */ + public function load_filter($type, $name) + { + } + /** + * deprecated clear_cache + * + * @param string $tpl_file name of template file + * @param string $cache_id name of cache_id + * @param string $compile_id name of compile_id + * @param string $exp_time expiration time + * + * @return boolean + * @deprecated + */ + public function clear_cache($tpl_file = \null, $cache_id = \null, $compile_id = \null, $exp_time = \null) + { + } + /** + * deprecated clear_all_cache + * + * @param string $exp_time expire time + * + * @return boolean + * @deprecated + */ + public function clear_all_cache($exp_time = \null) + { + } + /** + * deprecated is_cached + * + * @param string $tpl_file name of template file + * @param string $cache_id + * @param string $compile_id + * + * @return bool + * @throws \Exception + * @throws \SmartyException + * @deprecated + */ + public function is_cached($tpl_file, $cache_id = \null, $compile_id = \null) + { + } + /** + * deprecated clear_all_assign + * @deprecated + */ + public function clear_all_assign() + { + } + /** + * deprecated clear_compiled_tpl + * + * @param string $tpl_file + * @param string $compile_id + * @param string $exp_time + * + * @return boolean results of {@link smarty_core_rm_auto()} + * @deprecated + */ + public function clear_compiled_tpl($tpl_file = \null, $compile_id = \null, $exp_time = \null) + { + } + /** + * deprecated template_exists + * + * @param string $tpl_file + * + * @return bool + * @throws \SmartyException + * @deprecated + */ + public function template_exists($tpl_file) + { + } + /** + * deprecated get_template_vars + * + * @param string $name + * + * @return array + * @deprecated + */ + public function get_template_vars($name = \null) + { + } + /** + * deprecated get_config_vars + * + * @param string $name + * + * @return array + * @deprecated + */ + public function get_config_vars($name = \null) + { + } + /** + * deprecated config_load + * + * @param string $file + * @param string $section + * @param string $scope + * @deprecated + */ + public function config_load($file, $section = \null, $scope = 'global') + { + } + /** + * deprecated get_registered_object + * + * @param string $name + * + * @return object + * @deprecated + */ + public function get_registered_object($name) + { + } + /** + * deprecated clear_config + * + * @param string $var + * @deprecated + */ + public function clear_config($var = \null) + { + } +} +} + +namespace { +/** + * XOOPS template file handler class. + * This class is responsible for providing data access mechanisms to the data source + * of XOOPS template file class objects. + * + * + * @author Kazumi Ono + * + * @todo this is not a XoopsPersistableObjectHandler? + */ +class XoopsTplfileHandler extends \XoopsObjectHandler +{ + /** + * create a new XoopsTplfile + * + * @see \XoopsBlock + * @param bool $isNew is the new Tplfile new?? + * @return \XoopsTplfile reference to the new XoopsTplfile + */ + public function create($isNew = \true) + { + } + /** + * retrieve a specific {@link XoopsTplfile} + * + * @see \XoopsBlock + * + * @param int $id tpl_id of the block to retrieve + * @param bool $getsource + * + * @return \XoopsTplfile|false reference to the Tplfile + */ + public function get($id, $getsource = \false) + { + } + /** + * Load template source + * + * @param XoopsObject|XoopsTplfile $tplfile a XoopsTplfile object + * + * @return bool true on success, otherwise false + */ + public function loadSource(\XoopsObject $tplfile) + { + } + /** + * write a new Tplfile into the database + * + * @param XoopsObject|XoopsTplfile $tplfile a XoopsTplfile object + * + * @return bool true on success, otherwise false + */ + public function insert(\XoopsObject $tplfile) + { + } + /** + * Update XoopsTplfile, even if a safe transaction (i.e. http get) + * + * @param XoopsTplfile $tplfile + * @return bool true on success, otherwise false + */ + public function forceUpdate(\XoopsTplfile $tplfile) + { + } + /** + * delete a block from the database + * + * @param XoopsObject|XoopsTplfile $tplfile a XoopsTplfile object + * + * @return bool true on success, otherwise false + */ + public function delete(\XoopsObject $tplfile) + { + } + /** + * retrieve array of {@link XoopsBlock}s meeting certain conditions + * @param CriteriaElement|CriteriaCompo $criteria {@link CriteriaElement} with conditions for the blocks + * @param bool $getsource + * @param bool $id_as_key should the blocks' bid be the key for the returned array? + * @return array {@link XoopsBlock}s matching the conditions + */ + public function getObjects(?\CriteriaElement $criteria = \null, $getsource = \false, $id_as_key = \false) + { + } + /** + * Get count + * + * @param CriteriaElement|CriteriaCompo $criteria + * @return int + */ + public function getCount(?\CriteriaElement $criteria = \null) + { + } + /** + * getModuleTplCount + * + * @param string $tplset + * @return array + */ + public function getModuleTplCount($tplset) + { + } + /** + * Find Template File + * + * @param string|null $tplset + * @param string|array|null $type + * @param string|null $refid + * @param string|null $module + * @param string|null $file + * @param bool|string $getsource + * @return array + */ + public function find($tplset = \null, $type = \null, $refid = \null, $module = \null, $file = \null, $getsource = \false) + { + } + /** + * Template Exists + * + * @param string $tplname + * @param string $tplset_name + * @return bool true if template exists, otherwise false + */ + public function templateExists($tplname, $tplset_name) + { + } +} +} + +namespace { +/** + * Class for users + * @author Kazumi Ono + * @copyright (c) 2000-2026 XOOPS Project (https://xoops.org) + * @package kernel + */ +class XoopsUser extends \XoopsObject +{ + /** + * Array of groups that user belongs to + * @var array + * @access private + */ + public $_groups = []; + /** + * @var bool is the user admin? + * @access private + */ + public $_isAdmin; + /** + * User's rank as returned by xoops_getrank(): title, image and id. + * + * Documented as a string historically, but xoops_getrank() has always returned an + * array and rank() assigns its result directly. + * + * @var array{title: string, image: string, id: int}|null + * @access private + */ + public $_rank; + /** + * @var bool is the user online? + * @access private + */ + public $_isOnline; + //PHP 8.2 Dynamic properties deprecated + public $uid; + public $name; + public $uname; + public $email; + public $url; + public $user_avatar; + public $user_regdate; + public $user_icq; + public $user_from; + public $user_sig; + public $user_viewemail; + public $actkey; + public $user_aim; + public $user_yim; + public $user_msnm; + public $pass; + public $posts; + public $attachsig; + public $rank; + public $level; + public $theme; + public $timezone_offset; + public $last_login; + public $umode; + public $uorder; + // RMV-NOTIFY + public $notify_method; + public $notify_mode; + public $user_occ; + public $bio; + public $user_intrest; + public $user_mailok; + public $user_language; + /** + * constructor + * @param array|null $id ID of the user to be loaded from the database. + */ + public function __construct($id = \null) + { + } + /** + * check if the user is a guest user + * + * @return bool returns false + * + */ + public function isGuest() + { + } + /** + * Updated by Catzwolf 11 Jan 2004 + * find the username for a given ID + * + * @param int $userid ID of the user to find + * @param int $usereal switch for username or realname + * @param bool $linked add a link + * @return string name of the user. name for 'anonymous' if not found. + */ + public static function getUnameFromId($userid, $usereal = 0, $linked = \false) + { + } + /** + * increase the number of posts for the user + * + * @deprecated + */ + public function incrementPost() + { + } + /** + * set the groups for the user + * + * @param array $groupsArr Array of groups that user belongs to + */ + public function setGroups($groupsArr) + { + } + /** + * get the groups that the user belongs to + * + * @return array array of groups + */ + public function &getGroups() + { + } + /** + * alias for {@link getGroups()} + * @see getGroups() + * @return array array of groups + * @deprecated + */ + public function &groups() + { + } + /** + * Is the user admin ? + * + * This method will return true if this user has admin rights for the specified module.
    + * - If you don't specify any module ID, the current module will be checked.
    + * - If you set the module_id to -1, it will return true if the user has admin rights for at least one module + * + * @param int $module_id check if user is admin of this module + * @return bool is the user admin of that module? + */ + public function isAdmin($module_id = \null) + { + } + /** + * get the user's rank + * @return array array of rank ID and title + */ + public function rank() + { + } + /** + * is the user activated? + * @return bool + */ + public function isActive() + { + } + /** + * is the user currently logged in? + * @return bool + */ + public function isOnline() + { + } + /** + * get the users UID + * @param string $format + * @return int + */ + public function uid($format = '') + { + } + /** + * get the users UID + * @param string $format + * @return int + */ + public function id($format = 'N') + { + } + /** + * get the users name + * @param string $format format for the output, see {@link XoopsObject::getVar($format = '')} + * @return string + */ + public function name($format = 'S') + { + } + /** + * get the user's uname + * @param string $format format for the output, see {@link XoopsObject::getVar($format = '')} + * @return string + */ + public function uname($format = 'S') + { + } + /** + * get the user's email + * + * @param string $format format for the output, see {@link XoopsObject::getVar($format = '')} + * @return string + */ + public function email($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function url($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_avatar($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_regdate($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_icq($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_from($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_sig($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_viewemail($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function actkey($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_aim($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_yim($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_msnm($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function pass($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function posts($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function attachsig($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function level($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function theme($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function timezone($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function umode($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function uorder($format = '') + { + } + // RMV-NOTIFY + /** + * @param string $format + * + * @return mixed + */ + public function notify_method($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function notify_mode($format = '') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_occ($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function bio($format = 'S') + { + } + /** + * @param string $format + * + * @return mixed + */ + public function user_intrest($format = 'S') + { + } + /**#@-*/ + /**#@+ + * @deprecated + */ + public function getProfile() + { + } +} +} + +namespace { +/** + * Class xos_opal_Theme + */ +class xos_opal_Theme +{ + /** + * Should we render banner? Not for redirect pages or admin side + * + * @var bool + */ + public $renderBanner = \true; + /** + * The name of this theme + * + * @var string + */ + public $folderName = ''; + /** + * Physical path of this theme folder + * + * @var string + */ + public $path = ''; + public $url = ''; + /** + * Whether or not the theme engine should include the output generated by PHP + * + * @var string + */ + public $bufferOutput = \true; + /** + * Canvas-level template to use + * + * @var string + */ + public $canvasTemplate = 'theme.tpl'; + /** + * Theme folder path + * + * @var string + */ + public $themesPath = 'themes'; + /** + * Content-level template to use + * + * @var string + */ + public $contentTemplate = ''; + public $contentCacheLifetime = 0; + public $contentCacheId; + /** + * Text content to display right after the contentTemplate output + * + * @var string + */ + public $content = ''; + /** + * Page construction plug-ins to use + * + * @var array + * @access public + */ + public $plugins = ['xos_logos_PageBuilder']; + public $renderCount = 0; + /** + * Pointer to the theme template engine + * + * @var XoopsTpl + */ + public $template = \false; + /** + * Array containing the document meta-information + * + * @var array + */ + public $metas = [ + //'http' => array( + // 'Content-Script-Type' => 'text/javascript' , + // 'Content-Style-Type' => 'text/css') , + 'meta' => [], + 'link' => [], + 'script' => [], + ]; + /** + * Array of strings to be inserted in the head tag of HTML documents + * + * @var array + */ + public $htmlHeadStrings = []; + /** + * Custom variables that will always be assigned to the template + * + * @var array + */ + public $templateVars = []; + /** + * User extra information for cache id, like language, user groups + * + * @var boolean + */ + public $use_extra_cache_id = \true; + /** + * *#@- + */ + /** + * *#@+ + * + * @tasktype 10 Initialization + */ + /** + * Initializes this theme + * + * Upon initialization, the theme creates its template engine and instantiates the + * plug-ins from the specified {@link $plugins} list. If the theme is a 2.0 theme, that does not + * display redirection messages, the HTTP redirections system is disabled to ensure users will + * see the redirection screen. + * + * @param array $options + * @return bool + */ + public function xoInit($options = []) + { + } + /** + * Generate cache id based on extra information of language and user groups + * + * User groups other than anonymous should be detected to avoid disclosing group sensitive contents + * + * @param string $cache_id raw cache id + * @param string $extraString extra string + * @return string complete cache id + */ + public function generateCacheId($cache_id, $extraString = '') + { + } + /** + * xos_opal_Theme::checkCache() + * + * @return bool + */ + public function checkCache() + { + } + /** + * Render the page + * + * The theme engine builds pages from 2 templates: canvas and content. + * + * A module can call this method directly and specify what templates the theme engine must use. + * If render() hasn't been called before, the theme defaults will be used for the canvas and + * page template (and xoopsOption['template_main'] for the content). + * + * @param string $canvasTpl The canvas template, if different from the theme default + * @param string $pageTpl The page template, if different from the theme default (unsupported, 2.3+ only) + * @param string $contentTpl The content template + * @param array $vars Template variables to send to the template engine + * + * @return bool + */ + public function render($canvasTpl = \null, $pageTpl = \null, $contentTpl = \null, $vars = []) + { + } + /** + * Load localization information + * + * Folder structure for localization: + *
      themes/themefolder/english + *
    • main.php - language definitions
    • + *
    • style.css - localization stylesheet
    • + *
    • script.js - localization script
    • + *
    + * @param string $type + * @return bool + */ + public function loadLocalization($type = 'main') + { + } + /** + * Load theme specific language constants + * + * @param string $type language type, like 'main', 'admin'; Needs to be declared in theme xo-info.php + * @param string $language specific language + * + * @return bool|mixed + */ + public function addLanguage($type = 'main', $language = \null) + { + } + /** + * *#@+ + * + * @tasktype 20 Manipulating page meta-information + */ + /** + * Append a cache-busting version to a local asset URL. + * + * Admin theme stylesheets were served as bare paths with no version, so a + * browser holding an older copy showed none of a CSS change — the editor + * concludes the edit did not work, and a shipped fix silently never reaches + * anyone whose cache is warm. (The same defect, in a module, is why + * ModuleInstaller carries an ASSET_VERSION constant.) + * + * The token is the file's own mtime, so it changes exactly when the file + * changes: no constant to remember to bump, and edits are visible on reload + * during development. Costs one stat() per asset, which the OS caches. + * + * Left untouched: URLs that already carry a query string (the caller is + * versioning it themselves), and anything not under XOOPS_URL — a CDN asset + * is not ours to fingerprint and cannot be stat()ed. + * + * @param string $url absolute URL produced by XoopsPathTranslator + * + * @return string same URL, with ?v= when it can be resolved on disk + */ + protected function versionAsset($url) + { + } + /** + * Adds script code to the document head + * + * This methods allows the insertion of an external script file (if $src is provided), or + * of a script snippet. The file URI is parsed to take benefit of the theme resource + * overloading system. + * + * The $attributes parameter allows you to specify the attributes that will be added to the + * inserted '); + Utility::closeCollapsable('unsafe" id'); + $html = (string) ob_get_clean(); + + self::assertStringContainsString('id="unsafe__id"', $html); + self::assertStringContainsString('aria-controls="unsafe__id"', $html); + self::assertStringContainsString('<Title>', $html); + self::assertStringContainsString('<script>alert(1)</script>', $html); + self::assertStringNotContainsString('', $html); + self::assertStringEndsWith("\n", $html); + } +} diff --git a/tests/ApiSurfaceTest.php b/tests/ApiSurfaceTest.php new file mode 100644 index 0000000..c18d680 --- /dev/null +++ b/tests/ApiSurfaceTest.php @@ -0,0 +1,39 @@ + */ + public static function consumerApiClasses(): iterable + { + foreach ([ + 'Common\\Blocksadmin', + 'Common\\Configurator', + 'Common\\Db', + 'Common\\DirectoryChecker', + 'Common\\FilesManagement', + 'Common\\TestdataButtons', + 'Common\\TestdataSample', + 'Common\\UpdateChecker', + 'Module\\ConsumerRuntime', + 'Module\\Dependency', + 'Module\\Installer', + 'Module\\ModuleContext', + ] as $relativeClass) { + yield $relativeClass => [$relativeClass]; + } + } + + public function testCoreProviderNeedsNoInstalledModule(): void + { + $status = Bootstrap::checkRuntime('1.0.0', '1.2.0'); + + self::assertTrue($status['ok']); + self::assertSame('1.5.0', $status['module_version']); + } + + public function testLegacyBootstrapConstantsRemainAvailable(): void + { + self::assertSame('mtools', Bootstrap::MODULE_DIRNAME); + self::assertSame('1.1.0', Bootstrap::MIN_MODULE_VERSION); + self::assertSame(Bootstrap::MODULE_DIRNAME, \XoopsModules\Mtools\Bootstrap::MODULE_DIRNAME); + } + + public function testLegacyModuleDependencyIsSatisfiedByCoreCapability(): void + { + $status = Dependency::checkModule('mtools', '1.2.0', true); + + self::assertTrue($status['ok']); + self::assertSame(Bootstrap::VERSION, $status['module_version']); + self::assertSame('', ConsumerRuntime::dependencyError('1.0.0', '1.2.0')); + } + + public function testLegacyNamespaceResolvesToCoreLibrary(): void + { + self::assertTrue(class_exists(\XoopsModules\Mtools\Common\PaginationState::class)); + self::assertTrue(is_a( + \XoopsModules\Mtools\Common\PaginationState::class, + PaginationState::class, + true, + )); + } + + #[DataProvider('consumerApiClasses')] + public function testBundledConsumerApisResolveThroughBothNamespaces(string $relativeClass): void + { + $modernClass = 'Xoops\\ModuleTools\\' . $relativeClass; + $legacyClass = 'XoopsModules\\Mtools\\' . $relativeClass; + + $modernExists = class_exists($modernClass) || interface_exists($modernClass) || trait_exists($modernClass); + $legacyExists = class_exists($legacyClass) || interface_exists($legacyClass) || trait_exists($legacyClass); + + self::assertTrue($modernExists, $modernClass); + self::assertTrue($legacyExists, $legacyClass); + if (!trait_exists($modernClass)) { + self::assertTrue(is_a($legacyClass, $modernClass, true), $legacyClass); + } + } + + public function testPaginationContractRunsThroughModernNamespace(): void + { + $state = new PaginationState(total: 101, limit: 20, currentPage: 6); + + self::assertSame(6, $state->currentPage()); + self::assertSame(100, $state->offset()); + self::assertSame(101, $state->rangeEnd()); + } + +} diff --git a/tests/Common/ClonerTest.php b/tests/Common/ClonerTest.php new file mode 100644 index 0000000..7970272 --- /dev/null +++ b/tests/Common/ClonerTest.php @@ -0,0 +1,73 @@ +root = str_replace('\\', '/', sys_get_temp_dir()) . '/moduletools-cloner-' . bin2hex(random_bytes(4)); + mkdir($this->root . '/quotes/class', 0777, true); + mkdir($this->root . '/quotes/node_modules'); + file_put_contents($this->root . '/quotes/class/QuotesHandler.php', "root . '/quotes/logo.png', "\x89PNG quotes Quotes QUOTES"); + file_put_contents($this->root . '/quotes/node_modules/x.js', 'quotes'); + } + + protected function tearDown(): void + { + $this->rmdir($this->root); + } + + #[Test] + public function cloneRewritesNamesInTextFilesAndPathsButNotBinaries(): void + { + $target = Cloner::clone($this->root . '/quotes', 'Sayings'); + + self::assertSame($this->root . '/sayings', $target); + self::assertFileExists($target . '/class/SayingsHandler.php'); + self::assertStringEqualsFile($target . '/class/SayingsHandler.php', "expectException(\InvalidArgumentException::class); + Cloner::clone($this->root . '/quotes', '../evil'); + } + + #[Test] + public function cloneRefusesToOverwrite(): void + { + mkdir($this->root . '/taken'); + $this->expectException(\InvalidArgumentException::class); + Cloner::clone($this->root . '/quotes', 'taken'); + } + + #[Test] + public function createLogoFailsGracefullyWithoutAssets(): void + { + self::assertFalse(Cloner::createLogo($this->root . '/quotes')); + } + + private function rmdir(string $dir): void + { + foreach (array_diff(scandir($dir) ?: [], ['.', '..']) as $entry) { + $path = $dir . '/' . $entry; + is_dir($path) ? $this->rmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/Common/DirectoryCheckerTest.php b/tests/Common/DirectoryCheckerTest.php new file mode 100644 index 0000000..d24d527 --- /dev/null +++ b/tests/Common/DirectoryCheckerTest.php @@ -0,0 +1,70 @@ +uploadPath() . '/dc-missing'; + @rmdir($missing); + + $html = (string) DirectoryChecker::getDirectoryStatus($missing, 0755, '/admin/index.php'); + + self::assertStringContainsString('Not available', $html); + self::assertStringContainsString("value='mtools_createdir'", $html); + self::assertStringContainsString('Create it', $html); + self::assertStringContainsString("value='755'", $html); + } + + public function testExistingDirectoryReportsAvailableWithoutACreateButton(): void + { + $existing = $this->uploadPath() . '/dc-existing'; + self::assertTrue(DirectoryChecker::createDirectory($existing, 0755)); + + $html = (string) DirectoryChecker::getDirectoryStatus($existing, 0755, '/admin/index.php'); + + self::assertStringNotContainsString('mtools_createdir', $html); + rmdir($existing); + } + + public function testHandleRequestFailsClosedWithoutASecurityService(): void + { + $target = $this->uploadPath() . '/dc-csrf-' . uniqid('', true); + $_POST = ['op' => 'mtools_createdir', 'path' => $target, 'mode' => '0755']; + unset($GLOBALS['xoopsSecurity']); + + try { + DirectoryChecker::handleRequest('/admin/index.php'); + self::fail('handleRequest() must redirect when no security service is available'); + } catch (\RuntimeException $e) { + self::assertStringContainsString('Security token', $e->getMessage()); + } finally { + $_POST = []; + } + + self::assertDirectoryDoesNotExist($target); + } + + private function uploadPath(): string + { + if (!defined('XOOPS_URL')) { + define('XOOPS_URL', 'http://localhost'); + } + if (!defined('XOOPS_ROOT_PATH')) { + define('XOOPS_ROOT_PATH', sys_get_temp_dir()); + } + if (!defined('XOOPS_UPLOAD_PATH')) { + define('XOOPS_UPLOAD_PATH', sys_get_temp_dir()); + } + + return XOOPS_UPLOAD_PATH; + } +} diff --git a/tests/Common/ModuleConfigTest.php b/tests/Common/ModuleConfigTest.php new file mode 100644 index 0000000..6332a30 --- /dev/null +++ b/tests/Common/ModuleConfigTest.php @@ -0,0 +1,21 @@ +modCopyright); + self::assertSame([], $config->paths); + } +} diff --git a/tests/Common/ModuleStatsTest.php b/tests/Common/ModuleStatsTest.php new file mode 100644 index 0000000..f01a4bf --- /dev/null +++ b/tests/Common/ModuleStatsTest.php @@ -0,0 +1,28 @@ + */ + public array $moduleStats = [ + 'totalitems' => 12, + 'totalpublished' => 9, + ]; + }; + + self::assertTrue(method_exists(SysUtility::class, 'getModuleStats')); + self::assertSame( + ['existing' => 1, 'totalitems' => 12, 'totalpublished' => 9], + SysUtility::getModuleStats($configurator, ['existing' => 1]), + ); + } +} diff --git a/tests/Common/ServerStatsTest.php b/tests/Common/ServerStatsTest.php new file mode 100644 index 0000000..d0904b5 --- /dev/null +++ b/tests/Common/ServerStatsTest.php @@ -0,0 +1,65 @@ + 'GD: ', + 'GDLIBVERSION' => 'GD version: ', + 'GDOFF' => 'off', + 'GDON' => 'on', + 'IMAGEINFO' => 'quotes-status', + 'MAXPOSTSIZE' => 'post: ', + 'MAXUPLOADSIZE' => 'upload: ', + 'MEMORYLIMIT' => 'memory: ', + 'OFF' => 'off', + 'ON' => 'on', + 'SERVERPATH' => 'root: ', + 'SERVERUPLOADSTATUS' => 'uploads: ', + 'SPHPINI' => 'php.ini', + 'UPLOADPATHDSC' => 'upload note', + ]; + + foreach ($labels as $suffix => $label) { + defined('_CO_QUOTES_' . $suffix) || define('_CO_QUOTES_' . $suffix, $label); + defined('_CO_MTOOLS_' . $suffix) || define('_CO_MTOOLS_' . $suffix, 'mtools-' . $label); + } +} + +namespace XoopsModules\Quotes { + final class ServerStatsFixture + { + use \Xoops\ModuleTools\Common\ServerStats; + } +} + +namespace Xoops\ModuleTools\Tests\Common { + use PHPUnit\Framework\TestCase; + use XoopsModules\Quotes\ServerStatsFixture; + + final class ServerStatsTest extends TestCase + { + public function testUsesConsumerLabelsAndEscapesTheServerPath(): void + { + $html = ServerStatsFixture::getServerStats(); + + self::assertStringContainsString('quotes-status', $html); + self::assertStringNotContainsString('mtools-quotes-status', $html); + self::assertStringContainsString('C:/test&root', $html); + self::assertStringNotContainsString('C:/test&root', $html); + } + } +} diff --git a/tests/ExtcalConsumerTest.php b/tests/ExtcalConsumerTest.php new file mode 100644 index 0000000..1c4d04b --- /dev/null +++ b/tests/ExtcalConsumerTest.php @@ -0,0 +1,53 @@ +getExtension()) { + continue; + } + if (str_contains(str_replace('\\', '/', $file->getPathname()), '/tests/')) { + continue; + } + + $source = (string) file_get_contents($file->getPathname()); + self::assertStringNotContainsString('XoopsModules\\Extcal\\Common', $source, $file->getPathname()); + } + + self::assertStringContainsString( + 'use Xoops\\ModuleTools\\Common\\SysUtility;', + (string) file_get_contents(self::$modulePath . '/class/Utility.php'), + ); + self::assertStringContainsString( + 'use Xoops\\ModuleTools\\Common\\Configurator;', + (string) file_get_contents(self::$modulePath . '/include/oninstall.php'), + ); + $updateSource = (string) file_get_contents(self::$modulePath . '/include/onupdate.php'); + self::assertStringContainsString('use Xoops\\ModuleTools\\Common\\{', $updateSource); + self::assertStringContainsString(' Migrate', $updateSource); + } +} diff --git a/tests/Internal/PackageLanguageTest.php b/tests/Internal/PackageLanguageTest.php new file mode 100644 index 0000000..dc2d18a --- /dev/null +++ b/tests/Internal/PackageLanguageTest.php @@ -0,0 +1,23 @@ +resolve( + hiddens: ['id' => 42], + action: '', + requestUri: '/delete.php?id=42', + object: 'Example', + title: 'Confirm', + label: 'Delete?', + moduleDirName: null, + ); + + self::assertSame(['id' => 42], $model->hiddens); + self::assertSame('/delete.php?id=42', $model->action); + self::assertSame('Confirm', $model->title); + self::assertSame('Delete?', $model->label); + } + + public function testSortBuilderReturnsSemanticLinksAndSelection(): void + { + $model = new SortControlBuilder()->build('/items', '/items.php', 20, 'asc', 'title', 'title'); + + self::assertSame('/items', $model->formAction); + self::assertSame('/items.php?start=20&sort=title&order=asc', $model->ascendingUrl); + self::assertSame('/items.php?start=20&sort=title&order=desc', $model->descendingUrl); + self::assertSame('selasc.png', $model->ascendingIcon); + self::assertSame('desc.png', $model->descendingIcon); + } + + public function testVersionPolicyPreservesFinalAndPrereleaseRules(): void + { + $policy = new VersionUpdatePolicy(); + + self::assertTrue($policy->hasStableUpdate('1.0.0_Final', '1.1.0', false)); + self::assertFalse($policy->hasStableUpdate('1.0.0_Final', '1.1.0-beta', true)); + self::assertSame('1.0.0', $policy->normalize('1.0.0_Final')); + } + + public function testInstallationPlanIsTransportFreeData(): void + { + $plan = new InstallationPlan(['/uploads/example'], ['/uploads/example'], [], '/module/blank.png'); + + self::assertSame(['/uploads/example'], $plan->uploadFolders); + self::assertSame('/module/blank.png', $plan->blankFileSource); + } +} diff --git a/tests/Migration/FilesystemDelegationTest.php b/tests/Migration/FilesystemDelegationTest.php new file mode 100644 index 0000000..d4a6e21 --- /dev/null +++ b/tests/Migration/FilesystemDelegationTest.php @@ -0,0 +1,55 @@ +temporaryDirectory = sys_get_temp_dir() . '/moduletools-' . bin2hex(random_bytes(8)); + self::assertTrue(Filesystem::mkdir($this->temporaryDirectory)); + } + + protected function tearDown(): void + { + if (is_dir($this->temporaryDirectory)) { + Filesystem::deleteDirectory($this->temporaryDirectory); + } + } + + public function testDirectoryCreationMatchesHelpersFilesystem(): void + { + $viaShim = $this->temporaryDirectory . '/shim'; + $viaTarget = $this->temporaryDirectory . '/target'; + + self::assertSame( + Filesystem::mkdir($viaTarget, 0755, false), + DirectoryChecker::createDirectory($viaShim, 0755, $this->temporaryDirectory), + ); + self::assertTrue(is_dir($viaShim)); + self::assertTrue(is_dir($viaTarget)); + } + + public function testFileCopyMatchesHelpersFilesystem(): void + { + $source = $this->temporaryDirectory . '/source.txt'; + $viaShim = $this->temporaryDirectory . '/shim.txt'; + $viaTarget = $this->temporaryDirectory . '/target.txt'; + file_put_contents($source, 'moduletools parity'); + + self::assertSame( + Filesystem::copy($source, $viaTarget), + FileChecker::copyFile($source, $viaShim, $this->temporaryDirectory), + ); + self::assertSame(file_get_contents($viaTarget), file_get_contents($viaShim)); + } +} diff --git a/tests/Migration/MigrateDelegationTest.php b/tests/Migration/MigrateDelegationTest.php new file mode 100644 index 0000000..d0f18a0 --- /dev/null +++ b/tests/Migration/MigrateDelegationTest.php @@ -0,0 +1,18 @@ +path('admin/index.php')); + self::assertSame(Path::module('quotes', 'admin/index.php'), $context->adminPath('index.php')); + self::assertSame(Url::module('quotes', 'index.php'), $context->url('index.php')); + self::assertSame(Url::module('quotes', 'admin/index.php'), $context->adminUrl('index.php')); + } +} diff --git a/tests/Migration/PaginationNonParityTest.php b/tests/Migration/PaginationNonParityTest.php new file mode 100644 index 0000000..2196b34 --- /dev/null +++ b/tests/Migration/PaginationNonParityTest.php @@ -0,0 +1,35 @@ += 1.5'); + } + $legacy = new PaginationState(total: 0); + $target = PaginatedResult::empty(); + + self::assertSame(1, $legacy->pageCount()); + self::assertSame(0, $target->totalPages()); + self::assertNotSame($legacy->pageCount(), $target->totalPages()); + } + + public function testLegacyBandAndSqlContractsHaveNoPaginatedResultEquivalent(): void + { + $legacy = new PaginationState(total: 101, limit: 20, currentPage: 6); + + self::assertSame([1, 2, 3, 4, 5, 6], $legacy->pages()); + self::assertSame(' LIMIT 100, 20', $legacy->limitClause()); + self::assertFalse(method_exists(PaginatedResult::class, 'pages')); + self::assertFalse(method_exists(PaginatedResult::class, 'limitClause')); + } +} diff --git a/tests/Module/NamespaceAutoloaderTest.php b/tests/Module/NamespaceAutoloaderTest.php new file mode 100644 index 0000000..b3107ca --- /dev/null +++ b/tests/Module/NamespaceAutoloaderTest.php @@ -0,0 +1,34 @@ +groups = [2, 4]; + $gateway->items = [7, 9]; + $gateway->right = true; + + self::assertSame([2, 4], $service->grantedGroups('item_view', 7)); + self::assertSame([7, 9], $service->grantedItems('item_view', [1, 2])); + self::assertTrue($service->isGranted('item_view', 7, [2])); + self::assertTrue($service->replace('item_view', 7, [2, 4])); + self::assertSame( + [ + ['delete', 42, 'item_view', 7], + ['add', 42, 'item_view', 7, 2], + ['add', 42, 'item_view', 7, 4], + ], + $gateway->writes, + ); + } + + public function testRejectsInvalidPermissionNamesAndIdentifiers(): void + { + $service = new ItemPermission(42, new RecordingPermissionGateway()); + + $this->expectException(\InvalidArgumentException::class); + $service->replace('../bad', 1, [2]); + } +} + +final class RecordingPermissionGateway implements PermissionGatewayInterface +{ + /** @var list */ + public array $groups = []; + /** @var list */ + public array $items = []; + public bool $right = false; + /** @var list> */ + public array $writes = []; + + public function groupIds(string $name, int $itemId, int $moduleId): array + { + return $this->groups; + } + + public function itemIds(string $name, array $groupIds, int $moduleId): array + { + return $this->items; + } + + public function checkRight(string $name, int $itemId, array $groupIds, int $moduleId): bool + { + return $this->right; + } + + public function delete(string $name, int $itemId, int $moduleId): bool + { + $this->writes[] = ['delete', $moduleId, $name, $itemId]; + + return true; + } + + public function add(string $name, int $itemId, int $groupId, int $moduleId): bool + { + $this->writes[] = ['add', $moduleId, $name, $itemId, $groupId]; + + return true; + } +} diff --git a/tests/QuotesConsumerTest.php b/tests/QuotesConsumerTest.php new file mode 100644 index 0000000..a8b4ef6 --- /dev/null +++ b/tests/QuotesConsumerTest.php @@ -0,0 +1,54 @@ +getExtension()) { + continue; + } + + $source = file_get_contents($file->getPathname()); + self::assertIsString($source); + self::assertStringNotContainsString('XoopsModules\\Mtools', $source, $file->getPathname()); + } + } +} diff --git a/tests/SmartModulesConsumerTest.php b/tests/SmartModulesConsumerTest.php new file mode 100644 index 0000000..c7acc10 --- /dev/null +++ b/tests/SmartModulesConsumerTest.php @@ -0,0 +1,146 @@ + */ + private static array $modulePaths; + + public static function setUpBeforeClass(): void + { + $modulesPath = dirname(__DIR__, 5) . '/modules'; + if (!is_dir($modulesPath . '/smartfaq')) { + self::markTestSkipped('Smart modules not present (needs a XOOPS tree five levels up)'); + } + self::$modulePaths = [ + 'smartfaq' => $modulesPath . '/smartfaq', + 'smartblocks' => $modulesPath . '/smartblocks', + 'smartmedia' => $modulesPath . '/smartmedia', + 'smartpartner' => $modulesPath . '/smartpartner', + ]; + + require_once self::$modulePaths['smartfaq'] . '/bootstrap.php'; + require_once self::$modulePaths['smartblocks'] . '/bootstrap.php'; + require_once self::$modulePaths['smartmedia'] . '/bootstrap.php'; + require_once self::$modulePaths['smartpartner'] . '/bootstrap.php'; + } + + /** @return iterable */ + public static function modules(): iterable + { + yield 'SmartFAQ' => [ + 'smartfaq', + \XoopsModules\Smartfaq\Utility::class, + 'smartfaq_moduletools_dependency_error', + ]; + yield 'SmartBlocks' => [ + 'smartblocks', + \XoopsModules\Smartblocks\Utility::class, + 'smartblocks_moduletools_dependency_error', + ]; + yield 'SmartMedia' => [ + 'smartmedia', + \XoopsModules\Smartmedia\Utility::class, + 'smartmedia_moduletools_dependency_error', + ]; + yield 'SmartPartner' => [ + 'smartpartner', + \XoopsModules\Smartpartner\Utility::class, + 'smartpartner_moduletools_dependency_error', + ]; + } + + #[DataProvider('modules')] + public function testConsumerLoadsDirectlyAgainstCoreLibrary( + string $dirname, + string $utilityClass, + string $dependencyFunction, + ): void { + self::assertSame('', $dependencyFunction()); + self::assertTrue(class_exists($utilityClass)); + self::assertTrue(method_exists($utilityClass, 'getServerStats')); + + $manifest = file_get_contents(self::$modulePaths[$dirname] . '/xoops_version.php'); + self::assertIsString($manifest); + self::assertStringNotContainsString("'min_modules'", $manifest); + self::assertStringNotContainsString('/modules/mtools/bootstrap.php', $manifest); + self::assertStringContainsString("'min_php'", $manifest); + self::assertStringContainsString("'8.2'", $manifest); + } + + #[DataProvider('modules')] + public function testProductionCodeDoesNotUseRemovedFrameworks( + string $dirname, + string $utilityClass, + string $dependencyFunction, + ): void { + unset($utilityClass, $dependencyFunction); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(self::$modulePaths[$dirname], \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if (!$file instanceof \SplFileInfo || 'php' !== $file->getExtension()) { + continue; + } + if (str_contains(str_replace('\\', '/', $file->getPathname()), '/tests/')) { + continue; + } + + $source = file_get_contents($file->getPathname()); + self::assertIsString($source); + $code = self::withoutComments($source); + self::assertStringNotContainsString('SmartObject', $code, $file->getPathname()); + self::assertStringNotContainsString('XoopsModules\\Mtools', $code, $file->getPathname()); + self::assertStringNotContainsString('/modules/mtools/', $code, $file->getPathname()); + } + } + + public function testSmartFaqMigrationRecognizesSupportedMysqlIntegerForms(): void + { + self::assertTrue(DatabaseMigration::isCompatibleIntegerDefinition( + "int(11) unsigned NOT NULL DEFAULT '0'", + )); + self::assertTrue(DatabaseMigration::isCompatibleIntegerDefinition( + 'int NOT NULL AUTO_INCREMENT', + true, + )); + self::assertFalse(DatabaseMigration::isCompatibleIntegerDefinition( + "smallint NOT NULL DEFAULT '0'", + )); + } + + public function testSmartBlocksUpdatePreservesCurrentImageAssets(): void + { + $updateHook = file_get_contents(self::$modulePaths['smartblocks'] . '/include/onupdate.php'); + $config = file_get_contents(self::$modulePaths['smartblocks'] . '/config/config.php'); + + self::assertIsString($updateHook); + self::assertIsString($config); + self::assertStringNotContainsString('Installer::removeOldAssets', $updateHook); + self::assertStringNotContainsString("'/images',", $config); + } + + private static function withoutComments(string $source): string + { + $code = ''; + foreach (token_get_all($source) as $token) { + if (is_string($token)) { + $code .= $token; + continue; + } + if (T_COMMENT !== $token[0] && T_DOC_COMMENT !== $token[0]) { + $code .= $token[1]; + } + } + + return $code; + } +} diff --git a/tests/Tools/ConsumerBridgeGeneratorTest.php b/tests/Tools/ConsumerBridgeGeneratorTest.php new file mode 100644 index 0000000..ef83c36 --- /dev/null +++ b/tests/Tools/ConsumerBridgeGeneratorTest.php @@ -0,0 +1,111 @@ +parseSql($sql); + + self::assertSame(['sample_items', 'sample_join', 'sample_meta'], array_keys($tables)); + self::assertSame(['id', 'title', 'body', 'price', 'published_at'], array_keys($tables['sample_items']['columns'])); + self::assertSame(['id'], $tables['sample_items']['primaryKey']); + self::assertTrue($tables['sample_items']['columns']['id']['autoIncrement']); + self::assertSame('float', $tables['sample_items']['columns']['price']['phpType']); + self::assertTrue($tables['sample_items']['columns']['published_at']['nullable']); + + $eligible = $generator->eligibleTables($tables); + + self::assertSame(['sample_items'], array_keys($eligible)); + self::assertSame('composite-primary-key', $tables['sample_join']['skipReason']); + self::assertSame('non-integer-primary-key', $tables['sample_meta']['skipReason']); + } + + public function testGeneratesAnExactRoundTripManifest(): void + { + $sql = <<<'SQL' +CREATE TABLE sample_items ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(200) NOT NULL DEFAULT '', + body TEXT NOT NULL, + published_at DATETIME DEFAULT NULL, + PRIMARY KEY (id) +); +SQL; + + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'moduletools-bridge-generator-' . bin2hex(random_bytes(6)); + mkdir($root, 0777, true); + + try { + $generator = new ConsumerBridgeGenerator(); + $result = $generator->generate( + modulePath: $root, + moduleDirname: 'sample', + moduleNamespace: 'Sample', + tables: $generator->eligibleTables($generator->parseSql($sql)), + allTables: $generator->parseSql($sql), + ); + + self::assertSame(1, $result['generatedTables']); + self::assertFileExists($root . '/class/Modern/SampleItemsEntity.php'); + self::assertFileExists($root . '/class/Modern/SampleItemsSchema.php'); + self::assertFileExists($root . '/class/Modern/SampleItemsRepository.php'); + self::assertFileExists($root . '/class/Modern/BridgedSampleItemsHandler.php'); + self::assertFileExists($root . '/tests/run-bridge-smoke.php'); + self::assertFileExists($root . '/docs/architecture/xoops4-bridge.md'); + + $entity = file_get_contents($root . '/class/Modern/SampleItemsEntity.php'); + self::assertIsString($entity); + self::assertStringContainsString("'title' => \$this->title", $entity); + self::assertStringContainsString("'body' => \$this->body", $entity); + + $manifest = file_get_contents($root . '/class/Modern/BridgeManifest.php'); + self::assertIsString($manifest); + self::assertStringContainsString("'rows' =>", $manifest); + self::assertStringNotContainsString("'row' =>", $manifest); + } finally { + if (is_dir($root)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($root); + } + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..298f9df --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,10 @@ + Date: Mon, 21 Sep 2026 03:22:18 -0400 Subject: [PATCH 02/22] docs: add getting-started, module migration and compatibility guides Getting started covers install, Helper/ModuleContext, install hooks, version guards, admin CRUD, DynamicObject forms, front-end helpers, permissions, sample data and language-constant resolution. The migration guide walks a module off copied Common classes or the mTools module in independently shippable steps. The compatibility policy states what the 1.x line keeps and the conditions for any removal. --- docs/compatibility-policy.md | 59 ++++++ docs/getting-started.md | 356 +++++++++++++++++++++++++++++++++++ docs/migrating-a-module.md | 210 +++++++++++++++++++++ 3 files changed, 625 insertions(+) create mode 100644 docs/compatibility-policy.md create mode 100644 docs/getting-started.md create mode 100644 docs/migrating-a-module.md diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md new file mode 100644 index 0000000..00f68ee --- /dev/null +++ b/docs/compatibility-policy.md @@ -0,0 +1,59 @@ +# Compatibility and support policy + +## What is promised + +Every public `Xoops\ModuleTools\*` API in the 1.x line — and every lazy +`XoopsModules\Mtools\*` alias to it — stays available for the **whole XOOPS 2.8 lifetime**. +A module written against ModuleTools 1.x, or against the mTools 1.x module, keeps running on +every 2.8.x release without code changes. + +"Available" means the class, its public methods, their parameters and their observable +behaviour. The compatibility gate in this repository (`composer api:compat`) rejects any change +to that surface that is not explicitly allowlisted with a reason. + +## What is not promised + +- **`Xoops\ModuleTools\Internal\*`** (marked `@internal`) may change in any release. +- Method `@deprecated` in the docblock keeps working but will not receive fixes beyond + security; the docblock names the replacement (for example `queryFAndCheck()` → + `queryAndCheck()` / `$db->exec()`). +- Behaviour that was a **bug** is fixed, not preserved: the 1.5.0 changelog lists the cases + (for example `Helper::getDirname()` — a method that never existed — now `dirname()`). + +## Removal rules + +A public surface may be removed only at a major boundary of the *next* platform line, and +only when **all** of the following hold: + +1. its replacement is stable and its behavioural test corpus passes; +2. executable migration tooling, or a verified manual recipe, has shipped; +3. the maintainers' consumer audit reports zero remaining use; +4. an ecosystem search for third-party use has been done and recorded; +5. the published compatibility period has expired; and +6. a removal notice has shipped at least one release before removal. + +Zero known use is a prerequisite, never permission by itself. + +## Release checkpoints + +| Checkpoint | What happens | Removal? | +|---|---|---| +| Every XOOPS 2.8.x release | API snapshot and compatibility gate re-run; parity corpora run | No | +| First XOOPS 4 compatibility release | A `moduletools-compat` package, migration recipes, open gaps and the support duration are published | No | +| Later XOOPS 4 releases | Ecosystem re-scan; newly eligible candidates announced | Only after the notice above | +| A XOOPS 4 major boundary | Candidates meeting all six rules removed; everything else retained | Per symbol | + +The support duration ("N releases") is announced with the first XOOPS 4 compatibility release. +Until it is announced, the period is open-ended and nothing is removed. + +## Versioning + +- **1.x** — XOOPS 2.8 line. Minor releases add; they do not remove. PHP floor: 8.4 since 1.5.0. +- The library's `Bootstrap::API_VERSION` is the compatibility contract a module can assert + with `ConsumerRuntime::guard()`; it changes only when a minor release adds API. + +## Reporting a compatibility break + +Open an issue with the module dirname, the ModuleTools version (`Bootstrap::VERSION`), the +call that changed, and the observed vs expected behaviour. A confirmed break in a promised +surface is treated as a release blocker. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..426f7b8 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,356 @@ +# Getting started with ModuleTools + +ModuleTools (`Xoops\ModuleTools\*`) is the set of shared services that XOOPS modules used to +carry as copied `class/Common/*` files: install helpers, admin CRUD tables, confirmation +dialogs, breadcrumbs, letter navigation, sample-data buttons, permission helpers, version +checks and more. On XOOPS 2.8 it is bundled with Core, so a module simply calls it. + +- [Installation](#installation) +- [The `Helper` is the entry point](#the-helper-is-the-entry-point) +- [Module install / update hooks](#module-install--update-hooks) +- [Version and dependency guards](#version-and-dependency-guards) +- [Admin pages](#admin-pages) +- [Objects and forms](#objects-and-forms) +- [Front-end helpers](#front-end-helpers) +- [Permissions](#permissions) +- [Sample data](#sample-data) +- [Language constants](#language-constants) +- [Everything else](#everything-else) + +Migrating an existing module? Read [migrating-a-module.md](migrating-a-module.md) after this. + +## Installation + +**XOOPS 2.8.0+** ships the library in `xoops_lib/vendor/xoops/moduletools`; it is autoloaded on +every request. Nothing to install, nothing to activate — there is no `mtools` module row. + +**Standalone / development:** + +```bash +composer require xoops/moduletools +``` + +Requirements: PHP 8.4+, `xoops/xmf ^1.3`, `xoops/helpers ^1.0`. Smarty is Core's; the library +ships no templates. + +**XOOPS 2.7.x** sites keep using the separately distributed mTools *module*; the class names +are the same (`XoopsModules\Mtools\*`), see [compatibility-policy.md](compatibility-policy.md). + +## The `Helper` is the entry point + +Almost every service takes the module's XMF helper: + +```php +use Xmf\Module\Helper; + +$helper = Helper::getHelper('mymodule'); // or your module's Helper subclass +$helper->dirname(); // 'mymodule' +$helper->path('admin/index.php'); +$helper->url('index.php'); +$helper->getConfig('itemsperpage'); +``` + +`ModuleContext` wraps the same things in a small value object and can define the legacy +`MYMODULE_URL` / `MYMODULE_PATH` / `MYMODULE_UPLOAD_PATH` … constants once, at bootstrap: + +```php +use Xoops\ModuleTools\Module\ModuleContext; + +$ctx = ModuleContext::fromHelper($helper); // or ModuleContext::for('mymodule') +$ctx->uploadPath('images'); // …/uploads/mymodule/images +$ctx->adminUrl('index.php'); +$ctx->defineConstants(); // keeps old code that reads MYMODULE_URL working +``` + +## Module install / update hooks + +Describe the module's filesystem needs once in `config/config.php` (an array or object): + +```php + 'mymodule', + 'uploadFolders' => [XOOPS_UPLOAD_PATH . '/mymodule', XOOPS_UPLOAD_PATH . '/mymodule/images'], + 'copyBlankFiles' => [XOOPS_UPLOAD_PATH . '/mymodule'], // gets an index.html + 'copyTestFolders' => [[__DIR__ . '/../testdata/images', XOOPS_UPLOAD_PATH . '/mymodule/images']], + 'templateFolders' => ['/templates/', '/templates/blocks/', '/templates/admin/'], + 'oldFiles' => ['/class/oldhelper.php'], + 'oldFolders' => ['/images'], + 'renameTables' => ['mymodule_old' => 'mymodule_items'], + 'renameColumns' => [], + 'moduleStats' => ['items' => ['table' => 'mymodule_items']], + 'modCopyright' => 'XOOPS', +]; +``` + +Then the hooks shrink to: + +```php +// include/oninstall.php +use Xoops\ModuleTools\Common\Configurator; +use Xoops\ModuleTools\Module\Installer; + +function xoops_module_pre_install_mymodule(\XoopsModule $module): bool +{ + $helper = \Xmf\Module\Helper::getHelper($module->getVar('dirname')); + Installer::prepare($module, Configurator::forModule($helper)); // upload folders + clean tables + return true; +} + +function xoops_module_install_mymodule(\XoopsModule $module): bool +{ + $helper = \Xmf\Module\Helper::getHelper($module->getVar('dirname')); + Installer::install($module, Configurator::forModule($helper)); // blank files, test folders + return true; +} +``` + +```php +// include/onupdate.php +function xoops_module_update_mymodule(\XoopsModule $module, ?int $previousVersion = null): bool +{ + $helper = \Xmf\Module\Helper::getHelper($module->getVar('dirname')); + $configurator = Configurator::forModule($helper); + + Installer::purgeHtmlTemplates($module); // legacy .html templates + Installer::removeOldAssets($module, $configurator); // oldFiles / oldFolders + Installer::createUploadFolders($configurator); + + $migrate = new \Xoops\ModuleTools\Common\Migrate($configurator); // renameTables/renameColumns + $migrate->synchronizeSchema(); + return true; +} +``` + +`Common\Migrate` extends `Xmf\Database\Migrate`; if you have no configurator-driven renames, +use `Xmf\Database\Migrate` directly. + +Standard module preferences (social bookmarks, sample-data button, developer tools) are +appended to `xoops_version.php` in one line: + +```php +\Xoops\ModuleTools\Common\StandardConfig::append($modversion, $moduleDirName); +// define _MI_MYMODULE_SOCIAL_BOOKMARKS(_DESC), _MI_MYMODULE_SHOW_SAMPLE_BUTTON(_DESC), +// _MI_MYMODULE_SHOW_DEV_TOOLS(_DESC) in language/english/modinfo.php +``` + +## Version and dependency guards + +```php +use Xoops\ModuleTools\Common\VersionChecks; // trait — use it in your Utility class +use Xoops\ModuleTools\Common\UpdateChecker; +use Xoops\ModuleTools\Module\Dependency; + +// in include/oninstall.php / onupdate.php +if (!Utility::checkVerXoops($module) || !Utility::checkVerPhp($module)) { + return false; // messages come from the module's min_xoops / min_php manifest entries +} + +// "a newer release is available" box on the admin index +$update = UpdateChecker::checkVerModule($helper); // reads $modversion['github_repo'], + // else XoopsModules25x/{dirname} +// another module must be installed +Dependency::requireModule('tag', '2.36', requireActive: true); // throws on failure +$status = Dependency::checkModule('tag', '2.36'); // ['ok' => bool, 'errors' => [...]] +``` + +`ConsumerRuntime::guard(XOOPS_URL)` at the top of an entry point redirects with a readable +message when the ModuleTools API version the module was written against is not available. + +## Admin pages + +Server statistics and the standard admin "about" bits: + +```php +use Xoops\ModuleTools\Common\ServerStats; // trait +$adminObject->displayNavigation(basename(__FILE__)); +echo Utility::getServerStats(); // GD, upload limits, PHP/MySQL versions … +``` + +Block management inside your own admin (instead of sending users to System → Blocks): + +```php +$blocksadmin = new \Xoops\ModuleTools\Common\Blocksadmin($GLOBALS['xoopsDB'], $helper); +switch ($op) { + case 'list': $blocksadmin->listBlocks(); break; + case 'edit': $blocksadmin->editBlock($bid); break; + case 'clone': $blocksadmin->cloneBlock($bid); break; + case 'delete': $blocksadmin->deleteBlock($bid); break; +} +``` + +Generated CRUD tables for any `XoopsPersistableObjectHandler`: + +```php +use Xoops\ModuleTools\Admin\{ObjectTable, ObjectColumn, ObjectController, SingleView, ObjectRow}; + +$handler = $helper->getHandler('Item'); + +// list +$table = new ObjectTable($handler, null, ['edit', 'delete']); +$table->addColumn(new ObjectColumn('title')); +$table->addColumn(new ObjectColumn('datesub', 'center', 120, 'getDateSub')); +$table->addFilter('status', 'getStatusList'); // method on the handler +$table->setDefaultSort('datesub'); $table->setDefaultOrder('DESC'); +$table->render(); + +// save / delete from the object's default form +$controller = new ObjectController($handler); +$controller->storeFromDefaultForm(_AM_MYMODULE_ITEM_CREATED, _AM_MYMODULE_ITEM_MODIFIED, 'item.php'); +$controller->handleObjectDeletion(); + +// read-only detail view +$view = new SingleView($item); +$view->addRow(new ObjectRow('title')); +$view->addRow(new ObjectRow('body', 'getBody')); +$view->render(); + +// CSV download +(new \Xoops\ModuleTools\Admin\Export($handler, $criteria, ['title', 'datesub']))->render('items.csv'); +``` + +Module cloning ("create *mymodule2* from *mymodule*") — one admin page: + +```php +// admin/clone.php +echo \Xoops\ModuleTools\Common\Cloner::handleAdminRequest($helper); // form on GET, clone on POST +``` + +## Objects and forms + +`DynamicObject` is a `XoopsObject` that also knows how to render its own form; the object +metadata drives `ObjectFormBuilder`, `ObjectTable` and `SingleView`. + +```php +use Xoops\ModuleTools\Object\DynamicObject; + +class Item extends DynamicObject +{ + public function __construct() + { + $this->initVar('item_id', XOBJ_DTYPE_INT, null, false); + $this->initVar('title', XOBJ_DTYPE_TXTBOX, '', true, 255, '', '', _AM_MYMODULE_TITLE); + $this->initVar('body', XOBJ_DTYPE_TXTAREA, '', false, null, '', '', _AM_MYMODULE_BODY); + $this->initVar('status', XOBJ_DTYPE_INT, 1, false, null, '', '', _AM_MYMODULE_STATUS); + $this->initVar('uid', XOBJ_DTYPE_INT, 0, false, null, '', '', _AM_MYMODULE_AUTHOR); + + $this->setControl('body', 'textarea'); // or 'dhtmltextarea', 'yesno', 'user', 'select', 'image', … + $this->setControl('status', ['name' => 'select', 'itemHandler' => 'item', 'method' => 'getStatusList']); + $this->setControl('uid', 'user'); + $this->hideFieldFromForm('item_id'); + $this->setFieldForSorting('title'); + $this->initCommonVar('dohtml'); // standard XOOPS flags: dohtml, dobr, doxcode, … + } +} + +$form = $item->getForm(_AM_MYMODULE_ITEM_EDIT, 'save', 'item.php'); // \XoopsThemeForm +$form->display(); +``` + +`SeoObject` adds `meta_keywords` / `meta_description` fields (`initSeoVars()`); +`PersistableHandler` is the matching handler base with per-item permission helpers and an +upload configuration (`setUploaderConfig()`). + +## Front-end helpers + +```php +use Xoops\ModuleTools\Common\{Breadcrumb, LetterChoice, Confirm, Output, SocialBookmarks, Text}; + +// breadcrumb — renders db:mymodule_common_breadcrumb.tpl (register it in xoops_version.php) +$breadcrumb = new Breadcrumb($helper->dirname()); +$breadcrumb->addLink(_MD_MYMODULE_HOME, $helper->url()); +$breadcrumb->addLink($category->getVar('title')); +$xoopsTpl->assign('breadcrumb', $breadcrumb->render()); + +// A–Z navigation — renders db:mymodule_letterschoice.tpl +$letters = new LetterChoice($helper, $itemHandler, $criteria, 'title', [], 'letter', $helper->url('index.php')); +$xoopsTpl->assign('letterchoice', $letters->render()); + +// "Are you sure?" dialog, translated with _CO_MYMODULE_DELETE_* if you define them +$confirm = Confirm::forModule($helper, ['ok' => 1, 'id' => $id, 'op' => 'delete'], '', $item->getVar('title')); +$xoopsTpl->assign('form', $confirm->getFormConfirm()->render()); + +// meta tags, editor, sorting selector, share buttons, safe HTML excerpts +Output::metaKeywords($keywords); +Output::metaDescription($description); +$editor = Output::getEditor($helper, ['name' => 'body', 'value' => $body, 'rows' => 20]); +$xoopsTpl->assign('bookmarks', SocialBookmarks::render($url, $title)); +$excerpt = Text::truncateHtml($body, 300); +``` + +Pagination: use the `render_pagination` Smarty plugin from `xoops/smartyextensions` for new +templates. `PaginationState` (`offset()`, `limitClause()`, `pages()`, `hasNext()` …) is the +PHP-side value object when you need the numbers; `Paginator` is the legacy HTML bar. + +## Permissions + +Item-level group permissions without touching `XoopsGroupPermHandler` directly: + +```php +use Xoops\ModuleTools\Permission\ItemPermission; + +$perm = ItemPermission::forModule($helper->getModule()); +$perm->replace('mymodule_view', $catId, [XOOPS_GROUP_ADMIN, XOOPS_GROUP_USERS]); +$perm->isGranted('mymodule_view', $catId, $xoopsUser?->getGroups() ?? [XOOPS_GROUP_ANONYMOUS]); +$visibleCats = $perm->grantedItems('mymodule_view', $groups); +$perm->delete('mymodule_view', $catId); +``` + +In a `PersistableHandler` subclass, `setGrantedObjectsCriteria($criteria, 'mymodule_view')` +narrows any listing to what the current user may see. + +## Sample data + +Ship `testdata/english/*.yml` (one file per table, `Xmf\Database\TableLoad` format) and a +`testdata/index.php` that dispatches to `TestdataSample`: + +```php +// testdata/index.php +$sample = new \Xoops\ModuleTools\Common\TestdataSample($helper); +match (\Xmf\Request::getCmd('op', '')) { + 'load' => $sample->loadData(), + 'save' => $sample->saveData(), + 'clear' => $sample->clearData(), + default => null, +}; +``` + +The admin buttons appear when the `displaySampleButton` preference (see `StandardConfig`) is on: + +```php +// admin/index.php +\Xoops\ModuleTools\Common\TestdataButtons::loadButtonConfig($adminObject, $helper, $helper->getConfig('displaySampleButton')); +``` + +## Language constants + +The library's own strings (`_CO_MTOOLS_*`) load automatically from its bundled English catalog. +Wherever a service shows text to the end user it first looks for **your** module's constant: + +| Service | Looks for first | Falls back to | +|---|---|---| +| `Confirm` | `_CO_MYMODULE_DELETE_CONFIRM`, `_CO_MYMODULE_DELETE_LABEL` … | `_CO_MTOOLS_*` | +| `ModuleFeedback` | `_CO_MYMODULE_FB_*` | `_CO_MTOOLS_FB_*` | +| `ServerStats` | `_CO_MYMODULE_GDLIBSTATUS` … | `_CO_MTOOLS_*` | +| `StandardConfig` | `_MI_MYMODULE_SHOW_SAMPLE_BUTTON` (required) | — | + +Define the module-specific ones in `language//common.php` (or `modinfo.php` for `_MI_`) +when you want them translated; otherwise the English library text is used. + +## Everything else + +| Need | Class | +|---|---| +| Image resize / thumbnails | `Common\Resizer` + `ResizeRequest` / `ResizeResult` | +| Validated uploads | `Common\Media\MediaUploadRequest` | +| Directory / file health checks on the admin index | `Common\DirectoryChecker`, `Common\FileChecker` | +| Recursive copy/move/delete | `Common\FilesManagement` (trait) | +| SQL helpers with an explicit DB handle | `Common\Db` (`queryAndCheck`, `fieldExists`, `enumerate`, `cloneRecord`) | +| Category tree select boxes | `Common\ObjectTree` | +| Highlight search terms | `Common\Highlighter` | +| Module statistics for the admin "about" page | `Common\ModuleStats` (trait) | +| Autoload a module namespace without Composer | `Module\NamespaceAutoloader::register('XoopsModules\\Mymodule\\', __DIR__ . '/class')` | + +`Common\SysUtility` / `Utility` bundle most of the above as one static facade for modules +that still call `Utility::truncateHtml()`-style helpers; new code should call the focused class. diff --git a/docs/migrating-a-module.md b/docs/migrating-a-module.md new file mode 100644 index 0000000..7a3defb --- /dev/null +++ b/docs/migrating-a-module.md @@ -0,0 +1,210 @@ +# Migrating an existing module to ModuleTools + +This guide is for modules that carry their own copies of the mTools-era helper classes — +`class/Common/Configurator.php`, `Breadcrumb.php`, `Confirm.php`, `SysUtility.php`, +`TestdataButtons.php`, `ServerStats.php` … — or that depend on the separate **mTools module** +(`XoopsModules\Mtools\*`). Either way the target is the same: delete the copies, call the +library, keep behaviour. + +Nothing here requires a rewrite. Every step is independently shippable, and a module that +stops after step 1 already works on XOOPS 2.8. + +- [Step 0 — Decide the floor](#step-0--decide-the-floor) +- [Step 1 — Drop the mTools module dependency (zero code changes)](#step-1--drop-the-mtools-module-dependency-zero-code-changes) +- [Step 2 — Delete the copied `Common` classes](#step-2--delete-the-copied-common-classes) +- [Step 3 — Fix the dirname bug the copies all had](#step-3--fix-the-dirname-bug-the-copies-all-had) +- [Step 4 — Collapse the install/update hooks](#step-4--collapse-the-installupdate-hooks) +- [Step 5 — Replace the constant block with `ModuleContext`](#step-5--replace-the-constant-block-with-modulecontext) +- [Step 6 — Recipes for the remaining helpers](#step-6--recipes-for-the-remaining-helpers) +- [Step 7 — Verify](#step-7--verify) +- [Worked example](#worked-example) +- [What NOT to migrate yet](#what-not-to-migrate-yet) + +## Step 0 — Decide the floor + +| Your `min_xoops` | What to do | +|---|---| +| 2.8.0 or later | Follow this guide; ModuleTools is bundled with Core. | +| 2.7.x and 2.8 | Follow this guide **using the `XoopsModules\Mtools\*` names**, and keep the mTools module in your 2.7 install instructions. The aliases resolve to the library on 2.8 and to the module on 2.7. | +| 2.5.x | Keep your local copies; ModuleTools is PHP 8.4+. | + +## Step 1 — Drop the mTools module dependency (zero code changes) + +If the module already uses `XoopsModules\Mtools\Common\*`, nothing has to change on 2.8: the +library registers a lazy `class_alias` for every `XoopsModules\Mtools\X` → `Xoops\ModuleTools\X`. + +Do remove anything that *checks for the module row*: + +```php +// before — fails on 2.8 because there is no "mtools" module installed +if (!\xoops_isActiveModule('mtools')) { redirect_header(...); } + +// after — checks the library API instead (works with the module on 2.7 too) +\Xoops\ModuleTools\Module\ConsumerRuntime::guard(XOOPS_URL); +``` + +and drop `'mtools'` from `$modversion['dependencies']` / any "requires mTools" text. + +## Step 2 — Delete the copied `Common` classes + +1. List what the module carries: `ls class/Common/`. +2. For each file that has a namesake in the library, delete the local copy and change the + `use` line. Nothing else changes — the public API is identical to the 1.x mTools classes. + +```php +// before +use XoopsModules\Mymodule\Common\Breadcrumb; +use XoopsModules\Mymodule\Common\Configurator; +use XoopsModules\Mymodule\Common\SysUtility; + +// after +use Xoops\ModuleTools\Common\Breadcrumb; +use Xoops\ModuleTools\Common\Configurator; +use Xoops\ModuleTools\Common\SysUtility; +``` + +3. Keep a local class only if it genuinely differs. Typical keepers seen in practice: + a `Text` that is the module's own *content* entity (not the HTML truncator), a + forum-specific `ObjectTree`, a `Migrate` subclass with module-specific SQL. Rename such + keepers so the name no longer collides (`ForumTree`, `SchemaMigration`). + +4. `class/Utility.php` usually extends the local `SysUtility` and uses the local traits: + +```php +// before +class Utility extends Common\SysUtility { use Common\VersionChecks; use Common\ServerStats; use Common\FilesManagement; } + +// after +use Xoops\ModuleTools\Common\{SysUtility, VersionChecks, ServerStats, FilesManagement}; +class Utility extends SysUtility { use VersionChecks; use ServerStats; use FilesManagement; } +``` + +## Step 3 — Fix the dirname bug the copies all had + +Most copied classes derived the module directory with `basename(dirname(__DIR__, 2))` or +`basename(__DIR__)`. Inside `class/Common/` that resolved to `"Common"` — so +`_CO_MYMODULE_*` language constants were never found and English fallbacks were shown. + +The library classes cannot guess your dirname, so **pass it**: + +| Copy did | Library call | +|---|---| +| `new Confirm($hiddens, $action, $object)` | `Confirm::forModule($helper, $hiddens, $action, $object)` | +| `new Breadcrumb()` | `new Breadcrumb($helper->dirname())` | +| `new Configurator()` | `Configurator::forModule($helper)` (or `new Configurator($helper->path())`) | +| `ModuleFeedback::getInstance()` | `new ModuleFeedback(null, $helper->dirname())` | +| `Utility::getServerStats()` | unchanged — reads `$GLOBALS['xoopsModule']` | + +`Xmf\Module\Helper` exposes the directory as **`$helper->dirname()`**. Some generated module +helpers add a `getDirname()` alias; the library never calls it, and neither should new code. + +## Step 4 — Collapse the install/update hooks + +Typical `include/oninstall.php` / `onupdate.php` copies are 100–200 lines of folder creation, +`index.html` copying, table dropping and old-asset removal. All of it is data in +`config/config.php`. The replacement is shown in full in +[getting-started.md → Module install / update hooks](getting-started.md#module-install--update-hooks). + +Keep in the hook only what is module-specific: the `checkVerXoops`/`checkVerPhp` guard and +any default group permissions. + +## Step 5 — Replace the constant block with `ModuleContext` + +```php +// before (include/common.php or preloads/core.php) — 20–50 lines of this +define('MYMODULE_URL', XOOPS_URL . '/modules/' . $moduleDirName); +define('MYMODULE_PATH', XOOPS_ROOT_PATH . '/modules/' . $moduleDirName); +define('MYMODULE_UPLOAD_PATH', XOOPS_UPLOAD_PATH . '/' . $moduleDirName); +// … + +// after +\Xoops\ModuleTools\Module\ModuleContext::for($moduleDirName)->defineConstants(); +``` + +Existing code that reads `MYMODULE_UPLOAD_PATH` keeps working. New code should ask the +context (`$ctx->uploadPath()`) or `Xoops\Helpers\Service\Path::module($dirname, 'admin/x.php')` +and stop depending on constants. + +## Step 6 — Recipes for the remaining helpers + +**Filesystem.** The library keeps the XOOPS-specific behaviour (`createFolder()` also writes +the anti-listing `index.html`; `rrmdir()`/`rmove()`/`rcopy()` require an administrator). +Keep those calls where that behaviour is the contract; use `Xoops\Helpers\Utility\Filesystem` +where the caller already validates paths: + +```php +FileChecker::copyFile($source, $destination, $allowedBase); // containment enforced +\Xoops\Helpers\Utility\Filesystem::copy($source, $destination); // plain copy +``` + +**Schema.** `Common\Migrate` only adds the `renameTables` / `renameColumns` handling from +`config/config.php`. Without those, extend `Xmf\Database\Migrate` directly. + +**Paths / URLs / prefs.** `ModuleContext` or the Helpers services: + +```php +$path = \Xoops\Helpers\Service\Path::module($dirname, 'admin/index.php'); +$url = \Xoops\Helpers\Service\Url::module($dirname, 'admin/index.php'); +$pref = \Xoops\Helpers\Service\Config::get($dirname . '.itemsperpage', 20); +``` + +**Database.** `SysUtility::queryAndCheck($db, $sql)` reads nothing global any more; prefer +`Common\Db::queryAndCheck($GLOBALS['xoopsDB'], $sql)` (same method, explicit handle). +`queryFAndCheck()` is deprecated — use `queryAndCheck()` for SELECTs and `$db->exec()` for writes. + +**Templates the library renders.** Register them in `xoops_version.php` as before; the names +are unchanged: `mymodule_common_breadcrumb.tpl`, `mymodule_letterschoice.tpl`. + +**Pagination.** Keep `Paginator` / `PaginationState` for now; see +[What NOT to migrate yet](#what-not-to-migrate-yet). + +**Language.** Delete the `_CO_MYMODULE_*` strings that merely duplicated the mTools English +text (`_CO_MYMODULE_GDLIBSTATUS`, `_CO_MYMODULE_LOAD_SAMPLEDATA` …). The library supplies +them. Keep — or add — only the ones you actually translate. + +## Step 7 — Verify + +1. `php -l` every changed file, then load the admin index, the "about" page and one + front page: every one of them exercises `Configurator`, `ServerStats`, `Breadcrumb`. +2. Grep for leftovers: + ```bash + grep -rn "Mymodule\\\\Common\\\\" . # should be empty (or only the keepers from step 2) + grep -rn "isActiveModule('mtools')" . + grep -rn "basename(__DIR__)" class/ # the step-3 bug + ``` +3. Run the library's static-analysis stub against the module so mistakes surface without a + running site — copy `stubs/xoops-core.stub` and `phpstan-bootstrap.php` from this + repository into your module's `phpstan.neon` `scanFiles` / `bootstrapFiles`. +4. Install/update the module on a scratch site and go through create → edit → delete → + permissions → sample data load/clear. + +## Worked example + +A 2.5-era content module (categories, items, ratings) with 14 copied `Common` classes: + +| Before | After | +|---|---| +| `class/Common/*` — 14 files, ~2 900 lines | 0 files; `class/Utility.php` extends `Xoops\ModuleTools\Common\SysUtility` | +| `include/oninstall.php` — 140 lines | 25 lines: version guard + `Installer::prepare()` / `Installer::install()` + default permissions | +| `include/onupdate.php` — 210 lines | 40 lines: `Installer::purgeHtmlTemplates()`, `removeOldAssets()`, `Migrate->synchronizeSchema()` | +| `include/common.php` — 46 `define()` lines | `ModuleContext::for($dirname)->defineConstants()` | +| `_CO_MYMODULE_*` — 61 constants | 9 (the ones the module translates) | +| Delete dialog untranslated in German | Translated — `Confirm::forModule()` receives the dirname | + +Diff size: −3 200 / +180 lines. Behaviour differences found in testing: none, plus the +dirname fix above. + +## What NOT to migrate yet + +- **`PaginationState` → `Xmf\Pagination\PaginatedResult`.** The two disagree on the zero-result + page count (1 vs 0) and XMF has no band/SQL helpers. Split callers into retrieval, page + metadata and rendering first; adopt the XMF result afterwards. +- **Admin CRUD (`ObjectTable`, `ObjectController`, `SingleView`).** Frozen compatibility API + through XOOPS 2.8; the XMF presentation successor is not stable yet. +- **`DynamicObject` / `PersistableHandler` → XMF repositories.** A XOOPS 4 topic. The bridge + exists in XMF 2 but switching the default handler before extracting forms, notifications + and permissions from the objects is a regression. +- **`Breadcrumb` / `LetterChoice` → SmartyExtensions plugins.** Same freeze; the template + contract stays as-is in 1.x. + +See [compatibility-policy.md](compatibility-policy.md) for how long the frozen APIs are kept. From 9c8a9041dd2c3f3873e80b96232c88bb29297ab1 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 03:31:43 -0400 Subject: [PATCH 03/22] ci(coderabbit): limit the review set to source and configuration CodeRabbit skips pull requests above 100 files. Exclude generated gate artifacts, stubs, the PHPStan baseline, docs, tests, scripts and repo metadata so src/ and the tool configuration get reviewed. --- .coderabbit.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6102824..5c9919e 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -8,6 +8,24 @@ tone_instructions: >- reviews: profile: "assertive" + # Keep the reviewed set under CodeRabbit's 100-file limit: skip generated + # artifacts, stubs, baselines, docs and repo metadata; review src/ and config. + path_filters: + - "!resources/**" + - "!stubs/**" + - "!phpstan-baseline.neon" + - "!docs/**" + - "!tests/**" + - "!scripts/**" + - "!.github/**" + - "!**/*.md" + - "!LICENSE" + - "!CODEOWNERS" + - "!.editorconfig" + - "!.gitattributes" + - "!.gitignore" + - "!.scrutinizer.yml" + - "!sonar-project.properties" request_changes_workflow: true high_level_summary: true poem: false From 3796e34e89fb9af935709646886086c974467b41 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 03:52:05 -0400 Subject: [PATCH 04/22] security(admin): fail closed on a missing CSRF service; resolve review findings Copilot's review of #1 found two more fail-open token checks and a set of pre-existing defects; all are addressed here. Security - ObjectController::storeFromDefaultForm()/handleObjectDeletion() and Cloner::handleAdminRequest() now reject the request when no security service (or its check()) is available, as DirectoryChecker already did. - TestdataButtons: the load/save/clear links carry the XOOPS token in both renderers, not only the hide/show toggle. Fixes - ObjectTable::addFilter() now renders a select per filter (options from the handler method) and applies the chosen value to the criteria, preserving sort/order/limit/start/group-by; an explicit "All" clears a configured default. - ObjectTable::addWithSelectedActions() renders a checkbox column and a "with selected" form posting op, [] ids and the token. - Db::cloneRecord() returns false after a failed source lookup instead of continuing with a non-array. - UpdateChecker: the cache key includes the repository so the optional $repository argument is not shadowed by an earlier result. - SmartModulesConsumerTest skips unless all four module trees exist. - Issue template: security contact link points at this repository. --- .github/ISSUE_TEMPLATE/config.yml | 2 +- phpstan-baseline.neon | 12 ---- src/Admin/ObjectController.php | 19 ++--- src/Admin/ObjectTable.php | 108 ++++++++++++++++++++++++++++- src/Common/Cloner.php | 7 +- src/Common/Db.php | 4 +- src/Common/TestdataButtons.php | 14 ++-- src/Common/UpdateChecker.php | 2 +- tests/SmartModulesConsumerTest.php | 8 ++- 9 files changed, 141 insertions(+), 35 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index cf6c684..0c97729 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Security report - url: https://github.com/xoops/xoops-helpers/security/policy + url: https://github.com/XOOPS/moduletools/security/policy about: Report security issues privately through the repository security policy. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 13b3193..b8c2e62 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -42,24 +42,12 @@ parameters: count: 1 path: src/Admin/ObjectTable.php - - - message: '#^Method Xoops\\ModuleTools\\Admin\\ObjectTable\:\:addWithSelectedActions\(\) has parameter \$actions with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Admin/ObjectTable.php - - message: '#^Method Xoops\\ModuleTools\\Admin\\ObjectTable\:\:setObjects\(\) has parameter \$objects with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Admin/ObjectTable.php - - - message: '#^Property Xoops\\ModuleTools\\Admin\\ObjectTable\:\:\$filters type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Admin/ObjectTable.php - - message: '#^Property Xoops\\ModuleTools\\Admin\\ObjectTable\:\:\$introButtons type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/src/Admin/ObjectController.php b/src/Admin/ObjectController.php index f3b2984..512ea6e 100644 --- a/src/Admin/ObjectController.php +++ b/src/Admin/ObjectController.php @@ -37,10 +37,7 @@ public function storeFromDefaultForm(string $createdMessage, string $modifiedMes if ('POST' !== ($_SERVER['REQUEST_METHOD'] ?? 'GET')) { return false; } - $security = self::runtimeSecurity(); - if (is_object($security) && !$security->check()) { - throw new \RuntimeException(defined('_NOPERM') ? _NOPERM : 'Invalid security token.'); - } + self::assertValidToken(); $key = (string) $this->handler->keyName; $id = Request::getInt($key, 0, 'POST'); @@ -95,10 +92,7 @@ public function handleObjectDeletion(string|false $confirmMessage = false, strin redirect_header((string) ($_SERVER['HTTP_REFERER'] ?? xoops_getenv('SCRIPT_NAME')), 3, defined('_NOPERM') ? _NOPERM : 'Record not found.'); } if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') && Request::getInt('confirm', 0, 'POST')) { - $security = self::runtimeSecurity(); - if (is_object($security) && !$security->check()) { - throw new \RuntimeException(defined('_NOPERM') ? _NOPERM : 'Invalid security token.'); - } + self::assertValidToken(); $redirect = Request::getString('redirect_page', xoops_getenv('SCRIPT_NAME'), 'POST'); $ok = $this->handler->delete($object); redirect_header($redirect, 2, $ok ? (defined('_DELETEDSUCCESS') ? _DELETEDSUCCESS : 'Deleted.') : (defined('_ERRORS') ? _ERRORS : 'Delete failed.')); @@ -148,4 +142,13 @@ private static function runtimeSecurity(): mixed { return $GLOBALS['xoopsSecurity'] ?? null; } + + /** Fail closed: without a security service no token can be checked, so no write happens. */ + private static function assertValidToken(): void + { + $security = self::runtimeSecurity(); + if (!is_object($security) || !method_exists($security, 'check') || !$security->check()) { + throw new \RuntimeException(defined('_NOPERM') ? _NOPERM : 'Invalid security token.'); + } + } } diff --git a/src/Admin/ObjectTable.php b/src/Admin/ObjectTable.php index 188d93f..662894c 100644 --- a/src/Admin/ObjectTable.php +++ b/src/Admin/ObjectTable.php @@ -17,7 +17,10 @@ class ObjectTable /** @var list */ protected array $actions; protected array $introButtons = []; + /** @var array */ protected array $filters = []; + /** @var list */ + protected array $withSelected = []; protected string $tableId = 'moduletools-object-table'; protected bool $showTools = true; protected ?array $objects = null; @@ -47,8 +50,15 @@ public function addActionButton(string $operation, string|false $caption = false { $this->actions[] = $operation; } + /** + * Bulk actions: renders a checkbox per row and a "with selected" form that POSTs + * `op=`, `[]=` and the XOOPS token to the current script. + * + * @param list $actions operation names the consumer's page handles + */ public function addWithSelectedActions(array $actions = []): void { + $this->withSelected = array_values(array_map(strval(...), $actions)); } public function addFilter(string $key, string $method, mixed $default = false): void { @@ -111,7 +121,60 @@ protected function fetchObjects(): array if (null !== $this->objects) { return array_values(array_filter($this->objects, static fn ($object): bool => $object instanceof \XoopsObject)); } - return array_values($this->handler->getObjects($this->criteria)); + return array_values($this->handler->getObjects($this->filteredCriteria())); + } + + /** The table criteria plus one equality clause per filter that carries a selected value. */ + protected function filteredCriteria(): ?\CriteriaCompo + { + if ([] === $this->filters) { + return $this->criteria; + } + $criteria = new \CriteriaCompo(); + if (null !== $this->criteria) { + $criteria->add($this->criteria); + $criteria->setSort($this->criteria->getSort()); + $criteria->setOrder($this->criteria->getOrder()); + $criteria->setLimit($this->criteria->getLimit()); + $criteria->setStart($this->criteria->getStart()); + $criteria->setGroupBy($this->criteria->getGroupby()); + } + foreach ($this->filters as $key => $filter) { + $value = $this->filterValue($key, $filter['default']); + if ('' !== $value) { + $criteria->add(new \Criteria($key, $value)); + } + } + + return $criteria; + } + + /** + * Selected filter value: the request's `filter_` when the parameter is present + * (an explicit '' means "All"), otherwise the configured default; '' applies no clause. + */ + protected function filterValue(string $key, mixed $default): string + { + if (\Xmf\Request::hasVar('filter_' . $key, 'GET')) { + return \Xmf\Request::getString('filter_' . $key, '', 'GET'); + } + + return (false !== $default && null !== $default) ? (string) $default : ''; + } + + /** + * @param array{method: string, default: mixed} $filter + * @return array option value => label, from the handler method named in addFilter() + */ + protected function filterOptions(array $filter): array + { + $method = $filter['method']; + if (!method_exists($this->handler, $method)) { + return []; + } + $options = $this->handler->{$method}(); + + return is_array($options) ? array_map(static fn ($label): string => (string) $label, $options) : []; } protected function buildHtml(): string @@ -121,7 +184,29 @@ protected function buildHtml(): string foreach ($this->introButtons as $button) { $html .= '

    ' . $escape($button['value']) . '

    '; } + $script = $escape(xoops_getenv('SCRIPT_NAME')); + if ([] !== $this->filters && $this->showTools) { + $html .= ''; + foreach ($this->filters as $key => $filter) { + $selected = $this->filterValue($key, $filter['default']); + $caption = $this->handler->create()->vars[$key]['form_caption'] ?? $key; + $html .= ' '; + } + $html .= ''; + } + $bulk = [] !== $this->withSelected; + if ($bulk) { + $html .= '
    '; + } $html .= ''; + if ($bulk) { + $html .= ''; + } foreach ($this->columns as $column) { $caption = false !== $column->caption ? $column->caption : ($this->handler->create()->vars[$column->key]['form_caption'] ?? $column->key); $html .= ''; @@ -133,6 +218,9 @@ protected function buildHtml(): string $key = (string) $this->handler->keyName; foreach ($this->fetchObjects() as $index => $object) { $html .= ''; + if ($bulk) { + $html .= ''; + } foreach ($this->columns as $column) { $method = $column->valueMethod; $isCustom = false !== $method && method_exists($object, $method); @@ -157,6 +245,24 @@ protected function buildHtml(): string $html .= ''; } $html .= '
    ' . $escape($caption) . '
    '; + if ($bulk) { + $security = self::runtimeSecurity(); + $html .= '

    ' + . ((is_object($security) && method_exists($security, 'getTokenHTML')) ? $security->getTokenHTML() : '') + . '

    '; + } return $html; } + + /** @legacy-global-accessor */ + private static function runtimeSecurity(): ?object + { + $security = $GLOBALS['xoopsSecurity'] ?? null; + + return is_object($security) ? $security : null; + } } diff --git a/src/Common/Cloner.php b/src/Common/Cloner.php index 21ec9d0..fdae918 100644 --- a/src/Common/Cloner.php +++ b/src/Common/Cloner.php @@ -127,9 +127,12 @@ public static function handleAdminRequest(\Xmf\Module\Helper $helper): string return '

    ' . \_CO_MTOOLS_CLONE_DSC . '

    ' . $form->render(); } + // Fail closed: no security service means no token check is possible, so no clone. $security = self::runtimeGlobal('xoopsSecurity'); - if (\is_object($security) && !$security->check()) { - \redirect_header('clone.php', 3, \implode('
    ', $security->getErrors())); + if (!\is_object($security) || !\method_exists($security, 'check') || !$security->check()) { + $errors = (\is_object($security) && \method_exists($security, 'getErrors')) ? \implode('
    ', $security->getErrors()) : ''; + \redirect_header('clone.php', 3, '' !== $errors ? $errors : 'Security token missing or invalid'); + exit; } $clone = \mb_strtolower(Request::getString('clone', '', 'POST')); $safe = \htmlspecialchars($clone, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); diff --git a/src/Common/Db.php b/src/Common/Db.php index b0b7e92..a556e4f 100644 --- a/src/Common/Db.php +++ b/src/Common/Db.php @@ -111,8 +111,10 @@ public static function cloneRecord(\XoopsMySQLDatabase $db, $tableName, string $ if ($db->isResultSet($result) && $result instanceof \mysqli_result) { $tempTable = $db->fetchArray($result); } - if (!$tempTable) { + if (!\is_array($tempTable) || [] === $tempTable) { \trigger_error($db->error()); + + return false; } // set the auto-incremented id's value to blank. unset($tempTable[$idField]); diff --git a/src/Common/TestdataButtons.php b/src/Common/TestdataButtons.php index 82b84a2..ea64a99 100644 --- a/src/Common/TestdataButtons.php +++ b/src/Common/TestdataButtons.php @@ -93,9 +93,11 @@ public static function loadButtonConfig( if (self::SHOW_BUTTONS == $displaySampleButton) { \xoops_loadLanguage('admin/modulesadmin', 'system'); - $adminObject->addItemButton(\constant('_CO_MTOOLS_LOAD_SAMPLEDATA'), $helper->url('testdata/index.php?op=load'), 'add'); - $adminObject->addItemButton(\constant('_CO_MTOOLS_SAVE_SAMPLEDATA'), $helper->url('testdata/index.php?op=save'), 'add'); - $adminObject->addItemButton(\constant('_CO_MTOOLS_CLEAR_SAMPLEDATA'), $helper->url('testdata/index.php?op=clear'), 'alert'); + // The data links carry the token too, so a testdata/index.php may validate it + // directly (the reference endpoint additionally confirms load/clear via xoops_confirm()). + $adminObject->addItemButton(\constant('_CO_MTOOLS_LOAD_SAMPLEDATA'), $helper->url('testdata/index.php?op=load' . $tokenParam), 'add'); + $adminObject->addItemButton(\constant('_CO_MTOOLS_SAVE_SAMPLEDATA'), $helper->url('testdata/index.php?op=save' . $tokenParam), 'add'); + $adminObject->addItemButton(\constant('_CO_MTOOLS_CLEAR_SAMPLEDATA'), $helper->url('testdata/index.php?op=clear' . $tokenParam), 'alert'); // $adminObject->addItemButton(constant('_CO_MTOOLS_EXPORT_SCHEMA'), $helper->url( 'testdata/index.php?op=exportschema'), 'add'); $adminObject->addItemButton(\constant('_CO_MTOOLS_HIDE_SAMPLEDATA_BUTTONS'), '?op=hide_buttons' . $tokenParam, 'delete'); } else { @@ -140,9 +142,9 @@ public static function renderHtml(\Xmf\Module\Helper $helper, ?int $displayFlag $out = self::sampleDataStyle() . '
    '; if ($show) { - $out .= $btn(\constant('_CO_MTOOLS_LOAD_SAMPLEDATA'), $helper->url('testdata/index.php?op=load')); - $out .= $btn(\constant('_CO_MTOOLS_SAVE_SAMPLEDATA'), $helper->url('testdata/index.php?op=save')); - $out .= $btn(\constant('_CO_MTOOLS_CLEAR_SAMPLEDATA'), $helper->url('testdata/index.php?op=clear'), 'danger'); + $out .= $btn(\constant('_CO_MTOOLS_LOAD_SAMPLEDATA'), $helper->url('testdata/index.php?op=load&XOOPS_TOKEN_REQUEST=' . $token)); + $out .= $btn(\constant('_CO_MTOOLS_SAVE_SAMPLEDATA'), $helper->url('testdata/index.php?op=save&XOOPS_TOKEN_REQUEST=' . $token)); + $out .= $btn(\constant('_CO_MTOOLS_CLEAR_SAMPLEDATA'), $helper->url('testdata/index.php?op=clear&XOOPS_TOKEN_REQUEST=' . $token), 'danger'); $out .= $btn(\constant('_CO_MTOOLS_HIDE_SAMPLEDATA_BUTTONS'), '?op=hide_buttons&XOOPS_TOKEN_REQUEST=' . $token, 'muted'); } else { $out .= $btn(\constant('_CO_MTOOLS_SHOW_SAMPLEDATA_BUTTONS'), '?op=show_buttons&XOOPS_TOKEN_REQUEST=' . $token); diff --git a/src/Common/UpdateChecker.php b/src/Common/UpdateChecker.php index d139ed6..022b093 100644 --- a/src/Common/UpdateChecker.php +++ b/src/Common/UpdateChecker.php @@ -54,7 +54,7 @@ public static function checkVerModule(\Xmf\Module\Helper $helper, ?string $sourc // deterministic given the remote release data, so caching the final // ?array preserves the existing return contract. return Cache::remember( - 'mtools_update_' . $moduleDirName, + 'mtools_update_' . $moduleDirName . '_' . \substr(\md5($repository), 0, 8), 3600, static function () use ($repository, $module, $default): ?array { $infoReleasesUrl = "https://api.github.com/repos/$repository/releases"; diff --git a/tests/SmartModulesConsumerTest.php b/tests/SmartModulesConsumerTest.php index c7acc10..9846108 100644 --- a/tests/SmartModulesConsumerTest.php +++ b/tests/SmartModulesConsumerTest.php @@ -16,15 +16,17 @@ final class SmartModulesConsumerTest extends TestCase public static function setUpBeforeClass(): void { $modulesPath = dirname(__DIR__, 5) . '/modules'; - if (!is_dir($modulesPath . '/smartfaq')) { - self::markTestSkipped('Smart modules not present (needs a XOOPS tree five levels up)'); - } self::$modulePaths = [ 'smartfaq' => $modulesPath . '/smartfaq', 'smartblocks' => $modulesPath . '/smartblocks', 'smartmedia' => $modulesPath . '/smartmedia', 'smartpartner' => $modulesPath . '/smartpartner', ]; + foreach (self::$modulePaths as $dirname => $path) { + if (!is_file($path . '/bootstrap.php')) { + self::markTestSkipped($dirname . ' module not present (needs a XOOPS tree five levels up)'); + } + } require_once self::$modulePaths['smartfaq'] . '/bootstrap.php'; require_once self::$modulePaths['smartblocks'] . '/bootstrap.php'; From 7b8f716f9752cd2100ae8203a681c33c1ce90ef1 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 04:48:52 -0400 Subject: [PATCH 05/22] security(output): escape request-derived sort controls; complete SingleView rows Second Copilot pass on #1. - Output::selectSorting(): the REQUEST_URI form action, sort hrefs, icon URLs and label are escaped at emission; SortControlBuilder encodes the sort key and normalises start to an integer. - SingleView honours ObjectRow header/class and the constructor's actions and headerAsRow, which were accepted but ignored. - ConsumerBridgeGenerator converts boolean columns with a numeric comparison instead of a bool cast. --- phpstan-baseline.neon | 12 --------- src/Admin/SingleView.php | 26 +++++++++++++++++-- src/Common/Output.php | 8 +++--- .../Presentation/SortControlBuilder.php | 2 +- .../Tools/ConsumerBridgeGenerator.php | 9 +++++-- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b8c2e62..75e64b5 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,18 +66,6 @@ parameters: count: 1 path: src/Admin/SingleView.php - - - message: '#^Property Xoops\\ModuleTools\\Admin\\SingleView\:\:\$actions is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Admin/SingleView.php - - - - message: '#^Property Xoops\\ModuleTools\\Admin\\SingleView\:\:\$headerAsRow is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Admin/SingleView.php - - message: '#^Property Xoops\\ModuleTools\\Admin\\SingleView\:\:\$userSide is never read, only written\.$#' identifier: property.onlyWritten diff --git a/src/Admin/SingleView.php b/src/Admin/SingleView.php index e47bfcd..15fccf8 100644 --- a/src/Admin/SingleView.php +++ b/src/Admin/SingleView.php @@ -28,13 +28,35 @@ public function render(bool $fetchOnly = false, bool $debug = false): ?string $escape = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); $html = '
    '; foreach ($this->rows as $row) { + $caption = $this->object->vars[$row->key]['form_caption'] ?? $row->key; + $class = false !== $row->class ? ' class="' . $escape($row->class) . '"' : ''; + if ($row->header) { + // A header row is a section title; with headerAsRow=false it is dropped entirely. + if ($this->headerAsRow) { + $html .= '' . $escape($caption) . ''; + } + continue; + } $value = false !== $row->valueMethod && method_exists($this->object, $row->valueMethod) ? $this->object->{$row->valueMethod}() : $this->object->getVar($row->key, 's'); - $caption = $this->object->vars[$row->key]['form_caption'] ?? $row->key; - $html .= '
    ' . $escape($caption) . '
    ' . $escape($value) . '
    '; + $html .= '' . $escape($caption) . '' . $escape($value) . ''; } $html .= '
    '; + // DynamicObject carries its handler; a plain XoopsObject may not, so read it dynamically. + $handler = get_object_vars($this->object)['handler'] ?? null; + if ([] !== $this->actions && is_object($handler) && is_string($handler->keyName ?? null)) { + $key = (string) $handler->keyName; + $id = (int) $this->object->getVar($key, 'n'); + $script = $escape(xoops_getenv('SCRIPT_NAME')); + $links = []; + foreach ($this->actions as $action) { + $action = (string) $action; + $operation = 'edit' === $action ? 'mod' : ('delete' === $action ? 'del' : $action); + $links[] = '' . $escape(ucfirst($action)) . ''; + } + $html .= '

    ' . implode(' ', $links) . '

    '; + } if ($fetchOnly) { return $html; } diff --git a/src/Common/Output.php b/src/Common/Output.php index 8406f2c..201e9d2 100644 --- a/src/Common/Output.php +++ b/src/Common/Output.php @@ -52,9 +52,11 @@ public static function selectSorting($text, $form_sort, $helper): string currentSort: (string) $sort, requestedSort: (string) $form_sort, ); - $select_view = '
    ' . $text . ''; - $select_view .= ' ASC'; - $select_view .= 'DESC'; + // Everything here derives from the request (REQUEST_URI, sort/order) or the caller: escape at emission. + $escape = static fn (mixed $value): string => \htmlspecialchars((string) $value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + $select_view = '' . $escape($text) . ''; + $select_view .= ' ASC'; + $select_view .= 'DESC'; $select_view .= '
    '; return $select_view; diff --git a/src/Internal/Presentation/SortControlBuilder.php b/src/Internal/Presentation/SortControlBuilder.php index 7ab7c7f..4302538 100644 --- a/src/Internal/Presentation/SortControlBuilder.php +++ b/src/Internal/Presentation/SortControlBuilder.php @@ -17,7 +17,7 @@ public function build( ): SortControlResult { $ascendingIcon = $requestedSort === $currentSort && 'asc' === $currentOrder ? 'selasc.png' : 'asc.png'; $descendingIcon = $requestedSort === $currentSort && 'desc' === $currentOrder ? 'seldesc.png' : 'desc.png'; - $baseUrl = $scriptName . '?start=' . $start . '&sort=' . $requestedSort . '&order='; + $baseUrl = $scriptName . '?start=' . (int) $start . '&sort=' . rawurlencode($requestedSort) . '&order='; return new SortControlResult( formAction: $requestUri, diff --git a/src/Internal/Tools/ConsumerBridgeGenerator.php b/src/Internal/Tools/ConsumerBridgeGenerator.php index d190c91..28909c6 100644 --- a/src/Internal/Tools/ConsumerBridgeGenerator.php +++ b/src/Internal/Tools/ConsumerBridgeGenerator.php @@ -282,11 +282,16 @@ private function renderEntity(string $namespace, string $class, array $table, ar $parameters[] = ' public ' . $type . ' $' . $property . ' = ' . $default . ','; $fallback = $metadata['nullable'] ? 'null' : $default; $value = "\$row['" . $column . "'] ?? " . $fallback; + // Boolean columns arrive as strings from mysqli; compare numerically so any numeric zero + // ('0', '0.0', ' 0') reads as false, not only the literal '0' that (bool) already handles. + $cast = static fn (string $expr): string => 'bool' === $metadata['phpType'] + ? '0 !== (int) (' . $expr . ')' + : '(' . $metadata['phpType'] . ') (' . $expr . ')'; if ($metadata['nullable']) { $value = "array_key_exists('" . $column . "', \$row) && \$row['" . $column . "'] !== null" - . " ? (" . $metadata['phpType'] . ") \$row['" . $column . "'] : null"; + . ' ? ' . $cast("\$row['" . $column . "']") . ' : null'; } else { - $value = '(' . $metadata['phpType'] . ') (' . $value . ')'; + $value = $cast($value); } $from[] = ' ' . $property . ': ' . $value . ','; $to[] = " '" . $column . "' => \$this->" . $property . ','; From 784a9a4735ad606de6880ad7a46772a911f475c9 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 05:20:22 -0400 Subject: [PATCH 06/22] fix: resolve Copilot review round two - ObjectController: return after the not-found redirect in handleObjectDeletion - Blocksadmin: escape the raw block title in the hidden oldtitle input - ObjectFormBuilder: read control options only when the control is an array - ConsumerBridgeGenerator: allowlist criteria operators and connectors in the generated whereClause; skip the UPDATE for key-only tables - ItemPermission::replace: restore the previous grants when an add fails - LetterChoice: initialise $extra and require both suffix checks to fail - DynamicObject::accessGranted: return false when the module cannot be resolved --- src/Admin/ObjectController.php | 1 + src/Common/Blocksadmin.php | 2 +- src/Common/LetterChoice.php | 4 ++-- src/Form/ObjectFormBuilder.php | 2 +- .../Tools/ConsumerBridgeGenerator.php | 18 ++++++++++++---- src/Object/DynamicObject.php | 6 +++++- src/Permission/ItemPermission.php | 15 ++++++++++++- tests/Permission/ItemPermissionTest.php | 21 +++++++++++++++++++ tests/Tools/ConsumerBridgeGeneratorTest.php | 6 ++++++ 9 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/Admin/ObjectController.php b/src/Admin/ObjectController.php index 512ea6e..ebbed18 100644 --- a/src/Admin/ObjectController.php +++ b/src/Admin/ObjectController.php @@ -90,6 +90,7 @@ public function handleObjectDeletion(string|false $confirmMessage = false, strin $object = $id > 0 ? $this->handler->get($id) : false; if (!$object instanceof \XoopsObject || $object->isNew()) { redirect_header((string) ($_SERVER['HTTP_REFERER'] ?? xoops_getenv('SCRIPT_NAME')), 3, defined('_NOPERM') ? _NOPERM : 'Record not found.'); + return; } if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') && Request::getInt('confirm', 0, 'POST')) { self::assertValidToken(); diff --git a/src/Common/Blocksadmin.php b/src/Common/Blocksadmin.php index 6db2b84..5e398b5 100644 --- a/src/Common/Blocksadmin.php +++ b/src/Common/Blocksadmin.php @@ -224,7 +224,7 @@ public function listBlocks(): void "; } echo " - + diff --git a/src/Common/LetterChoice.php b/src/Common/LetterChoice.php index 58f5b53..e75b6c7 100644 --- a/src/Common/LetterChoice.php +++ b/src/Common/LetterChoice.php @@ -30,7 +30,7 @@ class LetterChoice private $field_name; private $alphabet; private $url; - private $extra; + private $extra = ''; /** * *#@- * @param mixed $modHelper @@ -83,7 +83,7 @@ public function __construct( } $this->alphabet = is_file($languageFile) ? require $languageFile : range('a', 'z'); $this->url = $url ?? $_SERVER['SCRIPT_NAME']; - if ('' !== $extra_arg && ('&' !== \mb_substr($extra_arg, -5) || '&' !== \mb_substr($extra_arg, -1))) { + if ('' !== $extra_arg && '&' !== \mb_substr($extra_arg, -5) && '&' !== \mb_substr($extra_arg, -1)) { $this->extra = '&' . $extra_arg; } } diff --git a/src/Form/ObjectFormBuilder.php b/src/Form/ObjectFormBuilder.php index 8403843..edf4a14 100644 --- a/src/Form/ObjectFormBuilder.php +++ b/src/Form/ObjectFormBuilder.php @@ -79,7 +79,7 @@ private static function element(DynamicObject $object, string $name, array $meta return $select; } if (in_array($controlName, ['select', 'select_multi', 'radio', 'check'], true)) { - $options = (array) ($control['options'] ?? []); + $options = is_array($control) ? (array) ($control['options'] ?? []) : []; if ('radio' === $controlName) { $element = new \XoopsFormRadio($caption, $name, $value); } elseif ('check' === $controlName) { diff --git a/src/Internal/Tools/ConsumerBridgeGenerator.php b/src/Internal/Tools/ConsumerBridgeGenerator.php index 28909c6..b28eba6 100644 --- a/src/Internal/Tools/ConsumerBridgeGenerator.php +++ b/src/Internal/Tools/ConsumerBridgeGenerator.php @@ -436,6 +436,9 @@ public function save(object $entity, ?bool $isNew = null): object $assignments[] = sprintf('`%s` = %s', $column, $this->renderValue($value)); } } + if ($assignments === []) { + return $entity; // key-only table: nothing to update + } $sql = sprintf( 'UPDATE `%s` SET %s WHERE `%s` = %d', $this->table, @@ -504,12 +507,17 @@ private function whereClause(TranslatedCriteria $criteria): string foreach ($criteria->conditions as $condition) { $column = (string) $condition['column']; $this->assertColumn($column); - $value = $condition['operator'] === 'IN' + $operator = strtoupper(trim((string) $condition['operator'])); + $connector = strtoupper(trim((string) ($condition['condition'] ?? 'AND'))); + if (!in_array($operator, self::OPERATORS, true) || !in_array($connector, ['AND', 'OR'], true)) { + throw new \LogicException(sprintf('%s rejects operator "%s" / connector "%s".', static::class, $operator, $connector)); + } + $value = in_array($operator, ['IN', 'NOT IN'], true) ? $this->renderList((array) $condition['value']) : $this->renderValue($condition['value']); - $prefix = $parts === [] ? '' : ' ' . $condition['condition'] . ' '; - $clause = sprintf('%s`%s` %s %s', $prefix, $column, $condition['operator'], $value); - if ($condition['operator'] === 'LIKE') { + $prefix = $parts === [] ? '' : ' ' . $connector . ' '; + $clause = sprintf('%s`%s` %s %s', $prefix, $column, $operator, $value); + if (in_array($operator, ['LIKE', 'NOT LIKE'], true)) { $clause .= ' ESCAPE ' . $this->db->quote('\\'); } $parts[] = $clause; @@ -536,6 +544,8 @@ private function renderValue(mixed $value): string }; } + private const OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN']; + private function assertColumn(string $column): void { if (!in_array($column, $this->columns, true)) { diff --git a/src/Object/DynamicObject.php b/src/Object/DynamicObject.php index 3600f88..4601f61 100644 --- a/src/Object/DynamicObject.php +++ b/src/Object/DynamicObject.php @@ -175,10 +175,14 @@ public function accessGranted(string $name): bool if (null === $this->handler || !method_exists($this->handler, 'getModuleInfo')) { return false; } + $module = $this->handler->getModuleInfo(); + if (!is_object($module)) { + return false; + } $user = self::runtimeUser(); $groups = is_object($user) ? $user->getGroups() : [XOOPS_GROUP_ANONYMOUS]; - return ItemPermission::forModule($this->handler->getModuleInfo())->isGranted($name, $this->id(), array_map(intval(...), $groups)); + return ItemPermission::forModule($module)->isGranted($name, $this->id(), array_map(intval(...), $groups)); } public function getForm(string $caption = '', string $operation = 'save', string|false $action = false, string|false $submitCaption = false, string|false $cancelAction = false, bool $captcha = false): \XoopsThemeForm diff --git a/src/Permission/ItemPermission.php b/src/Permission/ItemPermission.php index ec300a6..08c3772 100644 --- a/src/Permission/ItemPermission.php +++ b/src/Permission/ItemPermission.php @@ -52,10 +52,23 @@ public function isGranted(string $name, int $itemId, array $groupIds): bool public function replace(string $name, int $itemId, array $groupIds): bool { $this->assertArguments($name, $itemId); + $previous = $this->gateway->groupIds($name, $itemId, $this->moduleId); if (!$this->gateway->delete($name, $itemId, $this->moduleId)) { return false; } - return array_all($this->normalizeIds($groupIds), fn($groupId) => $this->gateway->add($name, $itemId, $groupId, $this->moduleId)); + if ($this->addAll($name, $itemId, $this->normalizeIds($groupIds))) { + return true; + } + // ponytail: no transaction on the gateway; best-effort restore of the previous grants + $this->gateway->delete($name, $itemId, $this->moduleId); + $this->addAll($name, $itemId, $this->normalizeIds($previous)); + return false; + } + + /** @param list $groupIds */ + private function addAll(string $name, int $itemId, array $groupIds): bool + { + return array_all($groupIds, fn($groupId) => $this->gateway->add($name, $itemId, $groupId, $this->moduleId)); } public function delete(string $name, int $itemId): bool diff --git a/tests/Permission/ItemPermissionTest.php b/tests/Permission/ItemPermissionTest.php index 85fc702..ad2771f 100644 --- a/tests/Permission/ItemPermissionTest.php +++ b/tests/Permission/ItemPermissionTest.php @@ -33,6 +33,23 @@ public function testReadsAndWritesPermissionsWithinOneModuleBoundary(): void ); } + public function testReplaceRestoresPreviousGrantsWhenAnAddFails(): void + { + $gateway = new RecordingPermissionGateway(); + $gateway->groups = [3, 5]; + $gateway->failAddFor = 9; + $service = new ItemPermission(12, $gateway); + + self::assertFalse($service->replace('item_view', 7, [8, 9])); + self::assertSame([ + ['delete', 12, 'item_view', 7], + ['add', 12, 'item_view', 7, 8], + ['delete', 12, 'item_view', 7], + ['add', 12, 'item_view', 7, 3], + ['add', 12, 'item_view', 7, 5], + ], $gateway->writes); + } + public function testRejectsInvalidPermissionNamesAndIdentifiers(): void { $service = new ItemPermission(42, new RecordingPermissionGateway()); @@ -49,6 +66,7 @@ final class RecordingPermissionGateway implements PermissionGatewayInterface /** @var list */ public array $items = []; public bool $right = false; + public ?int $failAddFor = null; /** @var list> */ public array $writes = []; @@ -76,6 +94,9 @@ public function delete(string $name, int $itemId, int $moduleId): bool public function add(string $name, int $itemId, int $groupId, int $moduleId): bool { + if ($groupId === $this->failAddFor) { + return false; + } $this->writes[] = ['add', $moduleId, $name, $itemId, $groupId]; return true; diff --git a/tests/Tools/ConsumerBridgeGeneratorTest.php b/tests/Tools/ConsumerBridgeGeneratorTest.php index ef83c36..a6f50e1 100644 --- a/tests/Tools/ConsumerBridgeGeneratorTest.php +++ b/tests/Tools/ConsumerBridgeGeneratorTest.php @@ -91,6 +91,12 @@ public function testGeneratesAnExactRoundTripManifest(): void self::assertStringContainsString("'title' => \$this->title", $entity); self::assertStringContainsString("'body' => \$this->body", $entity); + $repository = file_get_contents($root . '/class/Modern/SqlBridgeRepository.php'); + self::assertIsString($repository); + self::assertStringContainsString("private const OPERATORS = ['=',", $repository); + self::assertStringContainsString("in_array(\$connector, ['AND', 'OR'], true)", $repository); + self::assertStringContainsString('if ($assignments === [])', $repository); + $manifest = file_get_contents($root . '/class/Modern/BridgeManifest.php'); self::assertIsString($manifest); self::assertStringContainsString("'rows' =>", $manifest); From 473853e0ea9e6f5154eaea19408a2aab6a77ca8b Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 06:02:13 -0400 Subject: [PATCH 07/22] ci: keep the destination-matrix check deterministic without the private audit The consumer audit under docs/internal is never committed, so CI regenerated the matrix with every symbol marked unused and reported the committed artifacts as stale. When the audit is absent, carry consumer usage over from the committed destination-matrix.json instead. --- scripts/generate-destination-matrix.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/generate-destination-matrix.php b/scripts/generate-destination-matrix.php index f4cd009..c5b422c 100644 --- a/scripts/generate-destination-matrix.php +++ b/scripts/generate-destination-matrix.php @@ -5,9 +5,19 @@ $packageRoot = dirname(__DIR__); $checkOnly = in_array('--check', $argv, true); $surface = json_decode((string) file_get_contents($packageRoot . '/resources/gate/api-surface.json'), true, flags: JSON_THROW_ON_ERROR); -// The consumer audit is a private, site-specific scan; without it every symbol counts as unused. +// The consumer audit is a private, site-specific scan that is never committed. Without it +// (CI, fresh clones) consumer usage is carried over from the committed matrix so --check +// stays deterministic instead of flagging every symbol as unused. $auditFile = $packageRoot . '/docs/internal/consumer-audit.json'; -$audit = is_file($auditFile) ? json_decode((string) file_get_contents($auditFile), true, flags: JSON_THROW_ON_ERROR) : ['modules' => []]; +$matrixFile = $packageRoot . '/resources/gate/destination-matrix.json'; +$audit = ['modules' => [], 'carried' => []]; +if (is_file($auditFile)) { + $audit = json_decode((string) file_get_contents($auditFile), true, flags: JSON_THROW_ON_ERROR) + ['carried' => []]; +} elseif (is_file($matrixFile)) { + foreach (json_decode((string) file_get_contents($matrixFile), true, flags: JSON_THROW_ON_ERROR)['records'] ?? [] as $record) { + $audit['carried'][str_replace('()', '', (string) $record['symbol'])] = $record['consumers']; + } +} $decisions = require $packageRoot . '/resources/migration/destination-decisions.php'; $matrix = ['schema_version' => 1, 'records' => []]; @@ -66,6 +76,9 @@ function addRecord(array &$records, string $symbol, string $kind, array $decisio function consumerUsage(string $symbol, array $audit): array { $lookup = str_replace('()', '', $symbol); + if ($audit['modules'] === []) { + return $audit['carried'][$lookup] ?? ['usage_count' => 0, 'modules' => []]; + } $modules = []; $count = 0; foreach ($audit['modules'] as $module => $record) { From 7649bfdb02864a35f812c202eb8f68d6f28cc6fe Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 06:15:29 -0400 Subject: [PATCH 08/22] security: neutralise CSV formula prefixes and escape the confirmation object text - Export: prefix cells and headers starting with = + - @ tab or CR with a quote so spreadsheet clients read them as text; numeric values are left as-is - Confirm: HTML-escape the object text passed to the form label without double-encoding values that already came through getVar('s') - ObjectFormBuilder: document that the cancel action is developer-supplied JavaScript, escaped for the attribute only --- src/Admin/Export.php | 10 ++++++++-- src/Common/Confirm.php | 2 +- src/Form/ObjectFormBuilder.php | 2 ++ tests/Admin/ExportTest.php | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tests/Admin/ExportTest.php diff --git a/src/Admin/Export.php b/src/Admin/Export.php index 8140b12..d9bb3d9 100644 --- a/src/Admin/Export.php +++ b/src/Admin/Export.php @@ -47,18 +47,24 @@ public function render(string $filename): never foreach ($this->handler->getObjects($this->criteria) as $object) { $keys = array_values(array_filter(array_keys($object->vars), fn (string $key): bool => (false === $this->fields || in_array($key, $this->fields, true)) && !in_array($key, $this->excluded, true))); if (!$headerWritten) { - fputcsv($stream, array_map(fn (string $key): string => (string) ($object->vars[$key]['form_caption'] ?? $key), $keys), escape: ''); + fputcsv($stream, array_map(fn (string $key): string => self::csvCell((string) ($object->vars[$key]['form_caption'] ?? $key)), $keys), escape: ''); $headerWritten = true; } $row = []; foreach ($keys as $key) { $method = false !== $this->outputMethods ? ($this->outputMethods[$key] ?? null) : null; $value = is_string($method) && method_exists($object, $method) ? $object->{$method}() : $object->getVar($key, 's'); - $row[] = is_array($value) ? implode(', ', array_map(strval(...), $value)) : (string) $value; + $row[] = self::csvCell(is_array($value) ? implode(', ', array_map(strval(...), $value)) : (string) $value); } fputcsv($stream, $row, escape: ''); } fclose($stream); exit; } + + /** Spreadsheets evaluate cells starting with = + - @ or a tab/CR; a leading quote keeps them as text. */ + private static function csvCell(string $value): string + { + return '' !== $value && str_contains("=+-@ ", $value[0]) && !is_numeric($value) ? "'" . $value : $value; + } } diff --git a/src/Common/Confirm.php b/src/Common/Confirm.php index d4da326..6def2f4 100644 --- a/src/Common/Confirm.php +++ b/src/Common/Confirm.php @@ -103,7 +103,7 @@ public function getFormConfirm(): \XoopsThemeForm \xoops_load('XoopsFormLoader'); $form = new \XoopsThemeForm($model->title, 'formConfirm', $model->action, 'post', true); $form->setExtra('enctype="multipart/form-data"'); - $form->addElement(new \XoopsFormLabel($model->label, $model->object)); + $form->addElement(new \XoopsFormLabel($model->label, htmlspecialchars($model->object, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false))); foreach ($model->hiddens as $key => $value) { $form->addElement(new \XoopsFormHidden($key, $value)); } diff --git a/src/Form/ObjectFormBuilder.php b/src/Form/ObjectFormBuilder.php index edf4a14..f6a11dd 100644 --- a/src/Form/ObjectFormBuilder.php +++ b/src/Form/ObjectFormBuilder.php @@ -40,6 +40,8 @@ public static function build(DynamicObject $object, string $caption = '', string $tray->addElement(new \XoopsFormButton('', 'submit', false === $submitCaption ? _SUBMIT : $submitCaption, 'submit')); if (false !== $cancelAction) { $cancel = new \XoopsFormButton('', 'cancel', _CANCEL, 'button'); + // $cancelAction is a developer-supplied JavaScript statement (e.g. "location.href='index.php'"), + // never request data; it is escaped for the attribute only, callers JS-encode any embedded value. $cancel->setExtra('onclick="' . htmlspecialchars($cancelAction, ENT_QUOTES | ENT_HTML5) . '"'); $tray->addElement($cancel); } diff --git a/tests/Admin/ExportTest.php b/tests/Admin/ExportTest.php new file mode 100644 index 0000000..7984396 --- /dev/null +++ b/tests/Admin/ExportTest.php @@ -0,0 +1,32 @@ + */ + public static function cells(): iterable + { + yield 'formula' => ['=HYPERLINK("http://evil")', "'=HYPERLINK(\"http://evil\")"]; + yield 'plus' => ['+cmd|calc', "'+cmd|calc"]; + yield 'at' => ['@SUM(1)', "'@SUM(1)"]; + yield 'tab' => ["\t=1", "'\t=1"]; + yield 'negative number stays numeric' => ['-12.5', '-12.5']; + yield 'plain text' => ['hello', 'hello']; + yield 'empty' => ['', '']; + } + + #[DataProvider('cells')] + public function testNeutralisesSpreadsheetFormulaPrefixes(string $input, string $expected): void + { + $method = new \ReflectionMethod(Export::class, 'csvCell'); + + self::assertSame($expected, $method->invoke(null, $input)); + } +} From d7b680b93b083c89ba11bde5afa6aba5b191be7d Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 07:49:39 -0400 Subject: [PATCH 09/22] security: resolve Copilot review round four - composer: declare ext-mbstring, which Common\Text and others call unconditionally - ObjectController: return false when ItemPermission::replace() fails instead of redirecting with a success message - Cloner: skip symlinks so a clone never reads outside the module tree - NamespaceAutoloader: require every relative-class segment to be a PHP identifier before building the PSR-4 path (spl_autoload_call bypasses the engine's own name check) - UpdateChecker: bound the GitHub release lookup with connect and total timeouts --- composer.json | 1 + src/Admin/ObjectController.php | 9 +++++++-- src/Common/Cloner.php | 4 ++++ src/Common/UpdateChecker.php | 2 ++ src/Module/NamespaceAutoloader.php | 3 ++- tests/Common/ClonerTest.php | 14 ++++++++++++++ tests/Module/NamespaceAutoloaderTest.php | 6 ++++++ 7 files changed, 36 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 4eb6d42..5ebc2b3 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "homepage": "https://xoops.org", "require": { "php": "^8.4", + "ext-mbstring": "*", "symfony/yaml": "^6.0 || ^7.0", "xoops/helpers": "^1.0", "xoops/xmf": "^1.3" diff --git a/src/Admin/ObjectController.php b/src/Admin/ObjectController.php index ebbed18..1883ed4 100644 --- a/src/Admin/ObjectController.php +++ b/src/Admin/ObjectController.php @@ -58,8 +58,13 @@ public function storeFromDefaultForm(string $createdMessage, string $modifiedMes $permissions = ItemPermission::forModule($module); foreach ((array) $this->handler->getPermissions() as $definition) { $name = (string) ($definition['perm_name'] ?? ''); - if ('' !== $name) { - $permissions->replace($name, (int) $object->getVar($key, 'n'), array_map(intval(...), (array) ($_POST[$name] ?? []))); + if ('' === $name) { + continue; + } + // replace() restores the previous grants on failure; the object stays saved, + // so report the failure instead of redirecting with a success message. + if (!$permissions->replace($name, (int) $object->getVar($key, 'n'), array_map(intval(...), (array) ($_POST[$name] ?? [])))) { + return false; } } } diff --git a/src/Common/Cloner.php b/src/Common/Cloner.php index fdae918..ad5ca4e 100644 --- a/src/Common/Cloner.php +++ b/src/Common/Cloner.php @@ -176,6 +176,10 @@ private static function copyTree(string $source, string $target, array $search, } $from = $source . '/' . $entry; $to = $target . '/' . \str_replace($search, $replace, $entry); + if (\is_link($from)) { + // A link could point outside the module tree; a clone never dereferences it. + continue; + } if (\is_dir($from)) { self::copyTree($from, $to, $search, $replace, false); continue; diff --git a/src/Common/UpdateChecker.php b/src/Common/UpdateChecker.php index 022b093..3fb09c5 100644 --- a/src/Common/UpdateChecker.php +++ b/src/Common/UpdateChecker.php @@ -71,6 +71,8 @@ static function () use ($repository, $module, $default): ?array { curl_setopt($curlHandle, CURLOPT_URL, $infoReleasesUrl); curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curlHandle, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($curlHandle, CURLOPT_TIMEOUT, 10); curl_setopt($curlHandle, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($curlHandle, CURLOPT_HTTPHEADER, ['User-Agent: XOOPS mtools']); diff --git a/src/Module/NamespaceAutoloader.php b/src/Module/NamespaceAutoloader.php index 986f872..2abf9e6 100644 --- a/src/Module/NamespaceAutoloader.php +++ b/src/Module/NamespaceAutoloader.php @@ -43,7 +43,8 @@ static function (string $class) use ($namespacePrefix, $classDirectory): void { } $relativeClass = ltrim(substr($class, strlen($namespacePrefix)), '\\'); - if ($relativeClass === '') { + // Every segment must be a PHP identifier, so "..", "/" and "." never reach the path. + if (!preg_match('/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*$/', $relativeClass)) { return; } diff --git a/tests/Common/ClonerTest.php b/tests/Common/ClonerTest.php index 7970272..c24b2c0 100644 --- a/tests/Common/ClonerTest.php +++ b/tests/Common/ClonerTest.php @@ -41,6 +41,20 @@ public function cloneRewritesNamesInTextFilesAndPathsButNotBinaries(): void self::assertDirectoryDoesNotExist($target . '/node_modules'); } + #[Test] + public function cloneSkipsSymlinksInsteadOfFollowingThem(): void + { + file_put_contents($this->root . '/outside.txt', 'secret quotes'); + if (!@symlink($this->root . '/outside.txt', $this->root . '/quotes/link.txt')) { + self::markTestSkipped('symlink() is not permitted here'); + } + + $target = Cloner::clone($this->root . '/quotes', 'Sayings'); + + self::assertFileDoesNotExist($target . '/link.txt'); + self::assertFileExists($target . '/class/SayingsHandler.php'); + } + #[Test] public function cloneRejectsBadNamesAndExistingTargets(): void { diff --git a/tests/Module/NamespaceAutoloaderTest.php b/tests/Module/NamespaceAutoloaderTest.php index b3107ca..7ce9ec2 100644 --- a/tests/Module/NamespaceAutoloaderTest.php +++ b/tests/Module/NamespaceAutoloaderTest.php @@ -24,6 +24,12 @@ public function testLoadsPsr4AndLegacyFilenamesWithoutSharingModuleState(): void self::assertTrue(class_exists('Fixture\\Two\\LegacyName')); self::assertFalse(class_exists('Fixture\\OneMore\\Exact')); + file_put_contents($root . '/Secret.php', " Date: Mon, 21 Sep 2026 09:58:06 -0400 Subject: [PATCH 10/22] security: resolve Copilot review round five - PersistableHandler::setGrantedObjectsCriteria() fails closed: an empty grant list or missing module context adds an empty IN restriction (constant false on XOOPS 2.8) instead of leaving the criteria unfiltered - DirectoryChecker: form action and redirect accept only local paths; javascript:, data:, protocol-relative, backslash and control-character targets fall back to the current script - ConsumerBridgeGenerator: generated repositories allow only ASC/DESC as the ORDER BY direction and throw otherwise - ObjectController: a rejected upload stops the save before insert(), permissions and redirect - Cloner: remove the partial target when copyTree() fails so a retry is possible; a pre-existing target is never touched - ObjectFormBuilder: the cancel action is a local URL (or history.back()) JSON-encoded into a fixed location.href assignment, never an executed statement - ApiSurfaceTest: cover that an aliased legacy trait is usable (class_alias() has aliased traits since PHP 5.4.2) --- CHANGELOG.md | 13 +++++ resources/gate/api-surface.json | 19 ++++++- src/Admin/ObjectController.php | 3 +- src/Common/Cloner.php | 17 ++++-- src/Common/DirectoryChecker.php | 15 +++++- src/Form/ObjectFormBuilder.php | 29 ++++++++-- .../Tools/ConsumerBridgeGenerator.php | 6 ++- src/Persistence/PersistableHandler.php | 25 +++++---- tests/ApiSurfaceTest.php | 10 ++++ tests/Common/ClonerTest.php | 30 ++++++++++- tests/Common/DirectoryCheckerTest.php | 18 +++++++ tests/Form/ObjectFormBuilderTest.php | 54 +++++++++++++++++++ tests/Tools/ConsumerBridgeGeneratorTest.php | 2 + tests/fixtures/core-stubs.php | 7 +++ 14 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 tests/Form/ObjectFormBuilderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca65f9..7d10422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ## [Unreleased] ### Changed +- `Form\ObjectFormBuilder::build()` / `Object\DynamicObject::getForm()`: `$cancelAction` is now a + local URL (or `history.back()`), emitted as a fixed `location.href = "..."` assignment. It is no + longer executed as a JavaScript statement; a scheme, host or control character falls back to + the current script. - **Requires PHP 8.4+** (was 8.2). Rector PHP 8.4 + `xoops/rector-xoops` sets applied across `src/` (readonly value objects, constructor promotion, first-class callables, typed class constants). - `xoops/xmf` constraint widened to `^1.3` — every XMF API the library calls exists in 1.3.1; @@ -22,6 +26,15 @@ The format is based on Keep a Changelog and this project follows Semantic Versio instead of silently returning a partial clone. ### Fixed +- `Persistence\PersistableHandler::setGrantedObjectsCriteria()` always adds a key restriction: with + no grants or no module context the criteria now match nothing instead of every row. +- `Admin\ObjectController::storeFromDefaultForm()` no longer persists or redirects after a + rejected upload, and returns false when a permission update fails. +- `Common\DirectoryChecker` form action and redirect accept only local paths (`javascript:` and + `data:` schemes were passing). +- `Common\Cloner::clone()` removes its partial target when copying fails, and skips symlinks. +- `Internal\Tools\ConsumerBridgeGenerator`: generated repositories allow only `ASC`/`DESC` as + the ORDER BY direction. - `Xmf\Module\Helper::getDirname()` does not exist; every call site now uses `dirname()` (Blocksadmin, Cloner, Confirm, LetterChoice, TestdataButtons, TestdataSample). - `Object\DynamicObject::setErrors()` called a non-existent `setError()`. diff --git a/resources/gate/api-surface.json b/resources/gate/api-surface.json index bc7538f..1cc58f8 100644 --- a/resources/gate/api-surface.json +++ b/resources/gate/api-surface.json @@ -6045,6 +6045,23 @@ "return_type": "XoopsThemeForm", "throws": [], "documented_false_or_null": false + }, + "cancelHandler": { + "static": true, + "by_ref": false, + "parameters": [ + { + "name": "cancelAction", + "type": "string", + "by_ref": false, + "variadic": false, + "has_default": false, + "default": null + } + ], + "return_type": "string", + "throws": [], + "documented_false_or_null": false } } }, @@ -8464,7 +8481,7 @@ ], "return_type": "bool", "throws": [], - "documented_false_or_null": false + "documented_false_or_null": true }, "setUploaderConfig": { "static": false, diff --git a/src/Admin/ObjectController.php b/src/Admin/ObjectController.php index 1883ed4..8769ae7 100644 --- a/src/Admin/ObjectController.php +++ b/src/Admin/ObjectController.php @@ -48,7 +48,8 @@ public function storeFromDefaultForm(string $createdMessage, string $modifiedMes $isNew = $object->isNew(); $this->postDataToObject($object); $this->receiveUploads($object); - if (!$this->handler->insert($object)) { + // A rejected upload records errors on the object; never persist or redirect past it. + if ([] !== $object->getErrors() || !$this->handler->insert($object)) { return false; } diff --git a/src/Common/Cloner.php b/src/Common/Cloner.php index ad5ca4e..88bb386 100644 --- a/src/Common/Cloner.php +++ b/src/Common/Cloner.php @@ -11,6 +11,7 @@ */ use Xmf\Request; +use Xoops\Helpers\Utility\Filesystem; use Xoops\ModuleTools\Internal\PackageLanguage; /** @@ -66,7 +67,17 @@ public static function clone(string $sourcePath, string $newDirname): string \mb_strtoupper($oldDirname) => \mb_strtoupper($newDirname), \ucfirst(\mb_strtolower($oldDirname)) => \ucfirst($newDirname), ]; - self::copyTree($sourcePath, $targetPath, \array_keys($patterns), \array_values($patterns), true); + try { + self::copyTree($sourcePath, $targetPath, \array_keys($patterns), \array_values($patterns), true); + } catch (\Throwable $e) { + // $targetPath did not exist before this call, so it is ours to remove; a partial + // tree would otherwise block every retry with "Target already exists". + // Filesystem::deleteDirectory() unlinks symlinks without following them. + if (\is_dir($targetPath) && !Filesystem::deleteDirectory($targetPath)) { + throw new \RuntimeException(\sprintf('Clone failed and the partial target "%s" could not be removed', $targetPath), 0, $e); + } + throw $e; + } return $targetPath; } @@ -186,13 +197,13 @@ private static function copyTree(string $source, string $target, array $search, } $extension = \mb_strtolower(\pathinfo($from, \PATHINFO_EXTENSION)); if (\in_array($extension, self::BINARY_EXTENSIONS, true)) { - if (!\copy($from, $to)) { + if (!@\copy($from, $to)) { throw new \RuntimeException(\sprintf('File "%s" could not be copied to "%s"', $from, $to)); } continue; } $content = \file_get_contents($from); - if (false === $content || false === \file_put_contents($to, \str_replace($search, $replace, $content))) { + if (false === $content || false === @\file_put_contents($to, \str_replace($search, $replace, $content))) { throw new \RuntimeException(\sprintf('File "%s" could not be copied to "%s"', $from, $to)); } } diff --git a/src/Common/DirectoryChecker.php b/src/Common/DirectoryChecker.php index d5286b9..baf6b11 100644 --- a/src/Common/DirectoryChecker.php +++ b/src/Common/DirectoryChecker.php @@ -166,16 +166,27 @@ private static function actionForm(string $op, string $path, string $redirectFil . ''; } + /** Only a local path is accepted; anything with a scheme, host or control character falls back. */ private static function safeRedirect(?string $redirectFile): string { $redirectFile = (string)($redirectFile ?? ''); - if ('' === $redirectFile || \str_contains($redirectFile, '://') || \str_starts_with($redirectFile, '//')) { - $redirectFile = (string)($_SERVER['SCRIPT_NAME'] ?? 'index.php'); + if (!self::isLocalPath($redirectFile)) { + $fallback = (string)($_SERVER['SCRIPT_NAME'] ?? 'index.php'); + $redirectFile = self::isLocalPath($fallback) ? $fallback : 'index.php'; } return $redirectFile; } + private static function isLocalPath(string $path): bool + { + return '' !== $path + && !\str_starts_with($path, '//') + && !\str_contains($path, '\\') + && 1 !== \preg_match('/[\x00-\x1F\x7F]/', $path) + && 1 !== \preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path); + } + private static function isAllowedPath(string $path, ?string $allowedBasePath): bool { if ('' === $path || str_contains($path, "\0") || str_contains($path, '://')) { diff --git a/src/Form/ObjectFormBuilder.php b/src/Form/ObjectFormBuilder.php index f6a11dd..0cd5313 100644 --- a/src/Form/ObjectFormBuilder.php +++ b/src/Form/ObjectFormBuilder.php @@ -40,9 +40,7 @@ public static function build(DynamicObject $object, string $caption = '', string $tray->addElement(new \XoopsFormButton('', 'submit', false === $submitCaption ? _SUBMIT : $submitCaption, 'submit')); if (false !== $cancelAction) { $cancel = new \XoopsFormButton('', 'cancel', _CANCEL, 'button'); - // $cancelAction is a developer-supplied JavaScript statement (e.g. "location.href='index.php'"), - // never request data; it is escaped for the attribute only, callers JS-encode any embedded value. - $cancel->setExtra('onclick="' . htmlspecialchars($cancelAction, ENT_QUOTES | ENT_HTML5) . '"'); + $cancel->setExtra(self::cancelHandler($cancelAction)); $tray->addElement($cancel); } $form->addElement($tray); @@ -50,6 +48,31 @@ public static function build(DynamicObject $object, string $caption = '', string return $form; } + /** + * $cancelAction is a local URL the Cancel button navigates to, or "history.back()". + * It is never executed as JavaScript: the URL is JSON-encoded into a fixed + * `location.href = "..."` assignment, and a scheme or host falls back to the current script. + */ + public static function cancelHandler(string $cancelAction): string + { + $cancelAction = trim($cancelAction); + if (in_array($cancelAction, ['history.back()', 'history.go(-1)'], true)) { + return "onclick='history.back()'"; + } + if ( + '' === $cancelAction + || str_starts_with($cancelAction, '//') + || str_contains($cancelAction, '\\') + || 1 === preg_match('/[\x00-\x1F\x7F]/', $cancelAction) + || 1 === preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $cancelAction) + ) { + $cancelAction = (string) xoops_getenv('SCRIPT_NAME'); + } + $href = json_encode($cancelAction, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + return "onclick='location.href=" . $href . "'"; + } + /** @param array $metadata */ private static function element(DynamicObject $object, string $name, array $metadata): ?\XoopsFormElement { diff --git a/src/Internal/Tools/ConsumerBridgeGenerator.php b/src/Internal/Tools/ConsumerBridgeGenerator.php index b28eba6..c1df5a4 100644 --- a/src/Internal/Tools/ConsumerBridgeGenerator.php +++ b/src/Internal/Tools/ConsumerBridgeGenerator.php @@ -470,7 +470,11 @@ public function findBy(TranslatedCriteria $criteria): array $sql = 'SELECT * FROM `' . $this->table . '`' . $this->whereClause($criteria); if ($criteria->sort !== '') { $this->assertColumn($criteria->sort); - $sql .= sprintf(' ORDER BY `%s` %s', $criteria->sort, $criteria->order); + $order = strtoupper(trim($criteria->order)); + if (!in_array($order, ['ASC', 'DESC'], true)) { + throw new \LogicException(sprintf('%s rejects ORDER BY direction "%s".', static::class, $criteria->order)); + } + $sql .= sprintf(' ORDER BY `%s` %s', $criteria->sort, $order); } if ($criteria->limit > 0) { $sql .= sprintf(' LIMIT %d OFFSET %d', $criteria->limit, $criteria->start); diff --git a/src/Persistence/PersistableHandler.php b/src/Persistence/PersistableHandler.php index 145d60e..7090a58 100644 --- a/src/Persistence/PersistableHandler.php +++ b/src/Persistence/PersistableHandler.php @@ -130,20 +130,25 @@ public function getObjects2(?\CriteriaElement $criteria = null, bool $idAsKey = return parent::getObjects($criteria, $idAsKey, $asObject); } + /** + * Restrict $criteria to the items the current user's groups may see. + * + * Always adds a key restriction: with no grants (or no module context) the + * restriction is an empty IN, which XOOPS 2.8 renders as a constant-false + * predicate, so a caller that ignores the false return still lists nothing. + */ public function setGrantedObjectsCriteria(\CriteriaCompo $criteria, string $permissionName): bool { $module = $this->getModuleInfo(); - if (!is_object($module)) { - return false; + $ids = []; + if (is_object($module)) { + $user = self::runtimeUser(); + $groups = is_object($user) ? $user->getGroups() : [XOOPS_GROUP_ANONYMOUS]; + $ids = array_map(intval(...), \Xoops\ModuleTools\Permission\ItemPermission::forModule($module)->grantedItems($permissionName, array_map(intval(...), (array) $groups))); } - $user = self::runtimeUser(); - $groups = is_object($user) ? $user->getGroups() : [XOOPS_GROUP_ANONYMOUS]; - $ids = \Xoops\ModuleTools\Permission\ItemPermission::forModule($module)->grantedItems($permissionName, array_map(intval(...), (array) $groups)); - if ([] === $ids) { - return false; - } - $criteria->add(new \Criteria($this->keyName, array_map(intval(...), $ids), 'IN')); - return true; + $criteria->add(new \Criteria($this->keyName, $ids, 'IN')); + + return [] !== $ids; } /** @legacy-global-accessor */ diff --git a/tests/ApiSurfaceTest.php b/tests/ApiSurfaceTest.php index c18d680..8cac09d 100644 --- a/tests/ApiSurfaceTest.php +++ b/tests/ApiSurfaceTest.php @@ -36,4 +36,14 @@ public function testLegacyAliasRuleResolvesEveryRecordedSymbol(): void self::assertTrue($loaded, $legacy); } } + + public function testLegacyTraitAliasIsUsable(): void + { + // class_alias() has aliased traits since PHP 5.4.2 (php.net bug #61442). + self::assertTrue(trait_exists('XoopsModules\\Mtools\\Common\\ServerStats')); + $consumer = new class { + use \XoopsModules\Mtools\Common\ServerStats; + }; + self::assertTrue(method_exists($consumer, 'getServerStats')); + } } diff --git a/tests/Common/ClonerTest.php b/tests/Common/ClonerTest.php index c24b2c0..be9bbb5 100644 --- a/tests/Common/ClonerTest.php +++ b/tests/Common/ClonerTest.php @@ -66,8 +66,34 @@ public function cloneRejectsBadNamesAndExistingTargets(): void public function cloneRefusesToOverwrite(): void { mkdir($this->root . '/taken'); - $this->expectException(\InvalidArgumentException::class); - Cloner::clone($this->root . '/quotes', 'taken'); + file_put_contents($this->root . '/taken/keep.txt', 'keep'); + try { + Cloner::clone($this->root . '/quotes', 'taken'); + self::fail('an existing target must be refused'); + } catch (\InvalidArgumentException) { + } + self::assertFileExists($this->root . '/taken/keep.txt'); + } + + #[Test] + public function cloneRemovesThePartialTargetWhenCopyingFails(): void + { + // A directory "quotes" and a file "sayings" both map onto the target entry "sayings": + // whichever is copied second cannot be written, so copyTree() throws mid-tree. + mkdir($this->root . '/quotes/quotes'); + file_put_contents($this->root . '/quotes/quotes/inner.txt', 'x'); + file_put_contents($this->root . '/quotes/sayings', 'x'); + + try { + Cloner::clone($this->root . '/quotes', 'Sayings'); + self::fail('the colliding copy must throw'); + } catch (\RuntimeException) { + } + self::assertDirectoryDoesNotExist($this->root . '/sayings'); + + unlink($this->root . '/quotes/sayings'); + self::assertSame($this->root . '/sayings', Cloner::clone($this->root . '/quotes', 'Sayings')); + self::assertFileExists($this->root . '/sayings/sayings/inner.txt'); } #[Test] diff --git a/tests/Common/DirectoryCheckerTest.php b/tests/Common/DirectoryCheckerTest.php index d24d527..77ed79d 100644 --- a/tests/Common/DirectoryCheckerTest.php +++ b/tests/Common/DirectoryCheckerTest.php @@ -35,6 +35,24 @@ public function testExistingDirectoryReportsAvailableWithoutACreateButton(): voi rmdir($existing); } + public function testFormActionAcceptsOnlyLocalPaths(): void + { + $missing = $this->uploadPath() . '/dc-missing'; + @rmdir($missing); + $_SERVER['SCRIPT_NAME'] = '/admin/fallback.php'; + + try { + foreach (['javascript:alert(1)', 'data:text/html,x', 'https://evil.test/', '//evil.test/x', "\t/admin/x.php", '/admin/x\\y.php', ''] as $bad) { + $html = (string) DirectoryChecker::getDirectoryStatus($missing, 0755, $bad); + self::assertStringContainsString("
    uploadPath() . '/dc-csrf-' . uniqid('', true); diff --git a/tests/Form/ObjectFormBuilderTest.php b/tests/Form/ObjectFormBuilderTest.php new file mode 100644 index 0000000..5849955 --- /dev/null +++ b/tests/Form/ObjectFormBuilderTest.php @@ -0,0 +1,54 @@ +&"); + self::assertSame("onclick='location.href=\"index.php?x=\\u0027\\u0022\\u003C\\u003E\\u0026\"'", $handler); + } + + #[Test] + #[DataProvider('unsafeTargets')] + public function cancelFallsBackToTheCurrentScriptForUnsafeTargets(string $target): void + { + self::assertSame("onclick='location.href=\"/modules/quotes/admin/quote.php\"'", ObjectFormBuilder::cancelHandler($target), $target); + } + + /** @return iterable */ + public static function unsafeTargets(): iterable + { + yield 'javascript' => ['javascript:alert(1)']; + yield 'data' => ['data:text/html,x']; + yield 'absolute' => ['https://evil.test/']; + yield 'protocol relative' => ['//evil.test/x']; + yield 'control char' => ["index\x00.php"]; + yield 'empty' => ['']; + } +} diff --git a/tests/Tools/ConsumerBridgeGeneratorTest.php b/tests/Tools/ConsumerBridgeGeneratorTest.php index a6f50e1..d2225f7 100644 --- a/tests/Tools/ConsumerBridgeGeneratorTest.php +++ b/tests/Tools/ConsumerBridgeGeneratorTest.php @@ -96,6 +96,8 @@ public function testGeneratesAnExactRoundTripManifest(): void self::assertStringContainsString("private const OPERATORS = ['=',", $repository); self::assertStringContainsString("in_array(\$connector, ['AND', 'OR'], true)", $repository); self::assertStringContainsString('if ($assignments === [])', $repository); + self::assertStringContainsString("in_array(\$order, ['ASC', 'DESC'], true)", $repository); + self::assertStringNotContainsString('$criteria->sort, $criteria->order)', $repository); $manifest = file_get_contents($root . '/class/Modern/BridgeManifest.php'); self::assertIsString($manifest); diff --git a/tests/fixtures/core-stubs.php b/tests/fixtures/core-stubs.php index 4b23e75..c5f6118 100644 --- a/tests/fixtures/core-stubs.php +++ b/tests/fixtures/core-stubs.php @@ -27,3 +27,10 @@ function redirect_header(string $url, int $time = 0, string $message = ''): neve throw new \RuntimeException($message); } } + +if (!function_exists('xoops_getenv')) { + function xoops_getenv(string $key): string + { + return (string) ($_SERVER[$key] ?? ''); + } +} From 29b31f7c55d53140dafcd343a5c1d2bc8c281cda Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 10:09:16 -0400 Subject: [PATCH 11/22] security: close the review-of-review findings on round five - PersistableHandler::setGrantedObjectsCriteria() groups the conditions already present before AND-ing the key restriction, so an OR among them cannot bypass it ((a OR b) AND key IN (...)) - Output::localPath() is the one local-path check for DirectoryChecker and the cancel button; it rejects leading whitespace as well as control characters, backslashes, schemes and hosts, and validates the fallback down to a fixed index.php - Cloner::clone() creates the target exclusively so cleanup only ever removes a directory this call made; a symlink or a directory that appeared after the check is refused and left alone --- CHANGELOG.md | 5 +-- resources/gate/api-surface.json | 42 ++++++++++++++++++++++++++ src/Common/Cloner.php | 7 +++-- src/Common/DirectoryChecker.php | 19 ++---------- src/Common/Output.php | 24 +++++++++++++++ src/Form/ObjectFormBuilder.php | 19 ++++-------- src/Persistence/PersistableHandler.php | 11 +++++++ tests/Common/ClonerTest.php | 20 ++++++++++++ tests/Common/DirectoryCheckerTest.php | 2 +- tests/Form/ObjectFormBuilderTest.php | 9 ++++++ 10 files changed, 123 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d10422..4f51026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,11 +27,12 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ### Fixed - `Persistence\PersistableHandler::setGrantedObjectsCriteria()` always adds a key restriction: with - no grants or no module context the criteria now match nothing instead of every row. + no grants or no module context the criteria now match nothing instead of every row. Conditions + already in the criteria are grouped first so an OR among them cannot bypass the restriction. - `Admin\ObjectController::storeFromDefaultForm()` no longer persists or redirects after a rejected upload, and returns false when a permission update fails. - `Common\DirectoryChecker` form action and redirect accept only local paths (`javascript:` and - `data:` schemes were passing). + `data:` schemes were passing). The shared check is `Common\Output::localPath()`. - `Common\Cloner::clone()` removes its partial target when copying fails, and skips symlinks. - `Internal\Tools\ConsumerBridgeGenerator`: generated repositories allow only `ASC`/`DESC` as the ORDER BY direction. diff --git a/resources/gate/api-surface.json b/resources/gate/api-surface.json index 1cc58f8..dd22a80 100644 --- a/resources/gate/api-surface.json +++ b/resources/gate/api-surface.json @@ -4041,6 +4041,48 @@ "throws": [], "documented_false_or_null": true }, + "isLocalPath": { + "static": true, + "by_ref": false, + "parameters": [ + { + "name": "path", + "type": "string", + "by_ref": false, + "variadic": false, + "has_default": false, + "default": null + } + ], + "return_type": "bool", + "throws": [], + "documented_false_or_null": false + }, + "localPath": { + "static": true, + "by_ref": false, + "parameters": [ + { + "name": "path", + "type": "string", + "by_ref": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "fallback", + "type": "string", + "by_ref": false, + "variadic": false, + "has_default": true, + "default": "'index.php'" + } + ], + "return_type": "string", + "throws": [], + "documented_false_or_null": false + }, "metaDescription": { "static": true, "by_ref": false, diff --git a/src/Common/Cloner.php b/src/Common/Cloner.php index 88bb386..3392c46 100644 --- a/src/Common/Cloner.php +++ b/src/Common/Cloner.php @@ -58,7 +58,10 @@ public static function clone(string $sourcePath, string $newDirname): string throw new \InvalidArgumentException('Source module does not exist: ' . $sourcePath); } $targetPath = \dirname($sourcePath) . '/' . $newDirname; - if (\file_exists($targetPath)) { + // Exclusive creation establishes ownership: an existing directory, file or symlink + // (even one that appeared after a prior check) makes mkdir() fail, so only a + // target this call created is ever cleaned up below. + if (\file_exists($targetPath) || \is_link($targetPath) || !@\mkdir($targetPath)) { throw new \InvalidArgumentException('Target already exists: ' . $targetPath); } @@ -174,7 +177,7 @@ public static function handleAdminRequest(\Xmf\Module\Helper $helper): string /** @param list $search @param list $replace */ private static function copyTree(string $source, string $target, array $search, array $replace, bool $topLevel): void { - if (!\mkdir($target) && !\is_dir($target)) { + if (!@\mkdir($target) && !\is_dir($target)) { throw new \RuntimeException(\sprintf('Directory "%s" was not created', $target)); } $entries = \scandir($source, \SCANDIR_SORT_ASCENDING); diff --git a/src/Common/DirectoryChecker.php b/src/Common/DirectoryChecker.php index baf6b11..329423b 100644 --- a/src/Common/DirectoryChecker.php +++ b/src/Common/DirectoryChecker.php @@ -166,25 +166,10 @@ private static function actionForm(string $op, string $path, string $redirectFil . ''; } - /** Only a local path is accepted; anything with a scheme, host or control character falls back. */ + /** Only a local path is accepted (see {@see Output::localPath()}); the fallback is validated too. */ private static function safeRedirect(?string $redirectFile): string { - $redirectFile = (string)($redirectFile ?? ''); - if (!self::isLocalPath($redirectFile)) { - $fallback = (string)($_SERVER['SCRIPT_NAME'] ?? 'index.php'); - $redirectFile = self::isLocalPath($fallback) ? $fallback : 'index.php'; - } - - return $redirectFile; - } - - private static function isLocalPath(string $path): bool - { - return '' !== $path - && !\str_starts_with($path, '//') - && !\str_contains($path, '\\') - && 1 !== \preg_match('/[\x00-\x1F\x7F]/', $path) - && 1 !== \preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path); + return Output::localPath((string)($redirectFile ?? ''), (string)($_SERVER['SCRIPT_NAME'] ?? 'index.php')); } private static function isAllowedPath(string $path, ?string $allowedBasePath): bool diff --git a/src/Common/Output.php b/src/Common/Output.php index 201e9d2..6446338 100644 --- a/src/Common/Output.php +++ b/src/Common/Output.php @@ -125,4 +125,28 @@ public static function metaDescription($content): void $xoopsTpl->assign('xoops_metaDescription', \strip_tags($content)); } } + + /** + * A local navigation target (relative or root-relative path), or $fallback when $path is not one. + * + * Leading whitespace, control characters, backslashes, a scheme and a host are all rejected + * because browsers normalise them into a navigation (" javascript:x" runs). The fallback is + * validated the same way and ends at a fixed "index.php". + */ + public static function localPath(string $path, string $fallback = 'index.php'): string + { + if (self::isLocalPath($path)) { + return $path; + } + + return self::isLocalPath($fallback) ? $fallback : 'index.php'; + } + + public static function isLocalPath(string $path): bool + { + return '' !== $path + && 1 !== \preg_match('/^\s|[\x00-\x1F\x7F]|\\\\/', $path) + && !\str_starts_with($path, '//') + && 1 !== \preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path); + } } diff --git a/src/Form/ObjectFormBuilder.php b/src/Form/ObjectFormBuilder.php index 0cd5313..225d953 100644 --- a/src/Form/ObjectFormBuilder.php +++ b/src/Form/ObjectFormBuilder.php @@ -4,6 +4,7 @@ namespace Xoops\ModuleTools\Form; +use Xoops\ModuleTools\Common\Output; use Xoops\ModuleTools\Object\DynamicObject; /** Builds native XOOPS forms from DynamicObject field metadata. */ @@ -51,24 +52,16 @@ public static function build(DynamicObject $object, string $caption = '', string /** * $cancelAction is a local URL the Cancel button navigates to, or "history.back()". * It is never executed as JavaScript: the URL is JSON-encoded into a fixed - * `location.href = "..."` assignment, and a scheme or host falls back to the current script. + * `location.href = "..."` assignment. Anything that is not a local path (see + * {@see Output::localPath()}) falls back to the current script, validated the same way. */ public static function cancelHandler(string $cancelAction): string { - $cancelAction = trim($cancelAction); - if (in_array($cancelAction, ['history.back()', 'history.go(-1)'], true)) { + if (in_array(trim($cancelAction), ['history.back()', 'history.go(-1)'], true)) { return "onclick='history.back()'"; } - if ( - '' === $cancelAction - || str_starts_with($cancelAction, '//') - || str_contains($cancelAction, '\\') - || 1 === preg_match('/[\x00-\x1F\x7F]/', $cancelAction) - || 1 === preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $cancelAction) - ) { - $cancelAction = (string) xoops_getenv('SCRIPT_NAME'); - } - $href = json_encode($cancelAction, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $target = Output::localPath($cancelAction, (string) xoops_getenv('SCRIPT_NAME')); + $href = json_encode($target, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); return "onclick='location.href=" . $href . "'"; } diff --git a/src/Persistence/PersistableHandler.php b/src/Persistence/PersistableHandler.php index 7090a58..e39212d 100644 --- a/src/Persistence/PersistableHandler.php +++ b/src/Persistence/PersistableHandler.php @@ -136,6 +136,8 @@ public function getObjects2(?\CriteriaElement $criteria = null, bool $idAsKey = * Always adds a key restriction: with no grants (or no module context) the * restriction is an empty IN, which XOOPS 2.8 renders as a constant-false * predicate, so a caller that ignores the false return still lists nothing. + * Conditions already in $criteria are grouped first, so an OR among them + * cannot bypass the restriction ("(a OR b) AND key IN (...)"). */ public function setGrantedObjectsCriteria(\CriteriaCompo $criteria, string $permissionName): bool { @@ -146,6 +148,15 @@ public function setGrantedObjectsCriteria(\CriteriaCompo $criteria, string $perm $groups = is_object($user) ? $user->getGroups() : [XOOPS_GROUP_ANONYMOUS]; $ids = array_map(intval(...), \Xoops\ModuleTools\Permission\ItemPermission::forModule($module)->grantedItems($permissionName, array_map(intval(...), (array) $groups))); } + if ([] !== $criteria->criteriaElements) { + $existing = new \CriteriaCompo(); + foreach ($criteria->criteriaElements as $index => $element) { + $existing->add($element, (string) ($criteria->conditions[$index] ?? 'AND')); + } + $criteria->criteriaElements = []; + $criteria->conditions = []; + $criteria->add($existing); + } $criteria->add(new \Criteria($this->keyName, $ids, 'IN')); return [] !== $ids; diff --git a/tests/Common/ClonerTest.php b/tests/Common/ClonerTest.php index be9bbb5..75731c9 100644 --- a/tests/Common/ClonerTest.php +++ b/tests/Common/ClonerTest.php @@ -75,6 +75,22 @@ public function cloneRefusesToOverwrite(): void self::assertFileExists($this->root . '/taken/keep.txt'); } + #[Test] + public function cloneRefusesASymlinkTargetAndLeavesIt(): void + { + mkdir($this->root . '/elsewhere'); + if (!@symlink($this->root . '/elsewhere', $this->root . '/linked')) { + self::markTestSkipped('symlink() is not permitted here'); + } + try { + Cloner::clone($this->root . '/quotes', 'linked'); + self::fail('a symlink target must be refused'); + } catch (\InvalidArgumentException) { + } + self::assertTrue(is_link($this->root . '/linked')); + self::assertDirectoryExists($this->root . '/elsewhere'); + } + #[Test] public function cloneRemovesThePartialTargetWhenCopyingFails(): void { @@ -106,6 +122,10 @@ private function rmdir(string $dir): void { foreach (array_diff(scandir($dir) ?: [], ['.', '..']) as $entry) { $path = $dir . '/' . $entry; + if (is_link($path)) { + @unlink($path) || rmdir($path); // a directory symlink needs rmdir() on Windows + continue; + } is_dir($path) ? $this->rmdir($path) : unlink($path); } rmdir($dir); diff --git a/tests/Common/DirectoryCheckerTest.php b/tests/Common/DirectoryCheckerTest.php index 77ed79d..e931d2a 100644 --- a/tests/Common/DirectoryCheckerTest.php +++ b/tests/Common/DirectoryCheckerTest.php @@ -42,7 +42,7 @@ public function testFormActionAcceptsOnlyLocalPaths(): void $_SERVER['SCRIPT_NAME'] = '/admin/fallback.php'; try { - foreach (['javascript:alert(1)', 'data:text/html,x', 'https://evil.test/', '//evil.test/x', "\t/admin/x.php", '/admin/x\\y.php', ''] as $bad) { + foreach (['javascript:alert(1)', ' javascript:alert(1)', 'data:text/html,x', 'https://evil.test/', '//evil.test/x', ' //evil.test/x', "\t/admin/x.php", '/admin/x\\y.php', ''] as $bad) { $html = (string) DirectoryChecker::getDirectoryStatus($missing, 0755, $bad); self::assertStringContainsString("
    */ public static function unsafeTargets(): iterable { yield 'javascript' => ['javascript:alert(1)']; + yield 'leading space' => [' javascript:alert(1)']; + yield 'leading newline' => ["\n//evil.test/"]; yield 'data' => ['data:text/html,x']; yield 'absolute' => ['https://evil.test/']; yield 'protocol relative' => ['//evil.test/x']; From 96c08292be6d927c38102a912eb995e610ee7c8f Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 10:30:08 -0400 Subject: [PATCH 12/22] gate: allowlist the round-five API additions and refresh the destination matrix - Output::isLocalPath() is private; only localPath() is public API - allowlist Output::localPath(), ObjectFormBuilder::cancelHandler() and the setGrantedObjectsCriteria() docblock change - regenerate the destination matrix for the two new methods --- resources/gate/api-surface-allowlist.json | 5 +- resources/gate/api-surface.json | 17 ------ resources/gate/destination-matrix.json | 69 +++++++++++++++++++++++ resources/gate/destination-matrix.md | 2 + src/Common/Output.php | 2 +- 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/resources/gate/api-surface-allowlist.json b/resources/gate/api-surface-allowlist.json index db4c878..3ebc281 100644 --- a/resources/gate/api-surface-allowlist.json +++ b/resources/gate/api-surface-allowlist.json @@ -69,6 +69,9 @@ "symbols/Xoops\\ModuleTools\\Common\\ResizeResult/properties/width:added", "symbols/Xoops\\ModuleTools\\Common\\SocialBookmarks/constants/DEFAULT_NETWORKS/type:type-changed", "symbols/Xoops\\ModuleTools\\Bootstrap/constants/VERSION/value:value-changed", - "symbols/Xoops\\ModuleTools\\Common\\DirectoryChecker/methods/handleRequest:added" + "symbols/Xoops\\ModuleTools\\Common\\DirectoryChecker/methods/handleRequest:added", + "symbols/Xoops\\ModuleTools\\Common\\Output/methods/localPath:added", + "symbols/Xoops\\ModuleTools\\Form\\ObjectFormBuilder/methods/cancelHandler:added", + "symbols/Xoops\\ModuleTools\\Persistence\\PersistableHandler/methods/setGrantedObjectsCriteria/documented_false_or_null:value-changed" ] } diff --git a/resources/gate/api-surface.json b/resources/gate/api-surface.json index dd22a80..858eef3 100644 --- a/resources/gate/api-surface.json +++ b/resources/gate/api-surface.json @@ -4041,23 +4041,6 @@ "throws": [], "documented_false_or_null": true }, - "isLocalPath": { - "static": true, - "by_ref": false, - "parameters": [ - { - "name": "path", - "type": "string", - "by_ref": false, - "variadic": false, - "has_default": false, - "default": null - } - ], - "return_type": "bool", - "throws": [], - "documented_false_or_null": false - }, "localPath": { "static": true, "by_ref": false, diff --git a/resources/gate/destination-matrix.json b/resources/gate/destination-matrix.json index 687620d..c8be4f1 100644 --- a/resources/gate/destination-matrix.json +++ b/resources/gate/destination-matrix.json @@ -9850,6 +9850,39 @@ "modules": [] } }, + { + "symbol": "Xoops\\ModuleTools\\Common\\Output::localPath()", + "kind": "method", + "disposition": "retain-compat", + "qualifier": "until-output-modeled", + "coupling": [ + "xoops-runtime", + "presentation-html" + ], + "target_package": "none", + "target_symbol": "none", + "target_min_version": "none", + "target_owner": "none", + "target_status": "not-applicable", + "availability": "not-applicable", + "migration": [ + "none-yet" + ], + "rector_rules": [], + "rector_config_template": "", + "migration_safety": "manual", + "capability_profile": "", + "parity_test": "missing", + "upstream_issue": "", + "introduced_version": "", + "not_before_release": "", + "next_review_release": "", + "notes": "Frozen XOOPS 2.x compatibility surface.", + "consumers": { + "usage_count": 0, + "modules": [] + } + }, { "symbol": "Xoops\\ModuleTools\\Common\\Output::metaDescription()", "kind": "method", @@ -15191,6 +15224,42 @@ "modules": [] } }, + { + "symbol": "Xoops\\ModuleTools\\Form\\ObjectFormBuilder::cancelHandler()", + "kind": "method", + "disposition": "retain-compat", + "qualifier": "then-migrate", + "coupling": [ + "xoops-runtime", + "xoopsobject-handler", + "request-global", + "presentation-html" + ], + "target_package": "xoops/xmf", + "target_symbol": "Xmf\\Presentation\\DataTable", + "target_min_version": "2.0", + "target_owner": "XMF presentation and repository maintainers", + "target_status": "experimental", + "availability": "migrate-on-4.0", + "migration": [ + "adapter", + "manual-recipe" + ], + "rector_rules": [], + "rector_config_template": "", + "migration_safety": "manual", + "capability_profile": "", + "parity_test": "missing", + "upstream_issue": "", + "introduced_version": "", + "not_before_release": "", + "next_review_release": "", + "notes": "Keep frozen until the XMF presentation contract is stable.", + "consumers": { + "usage_count": 0, + "modules": [] + } + }, { "symbol": "Xoops\\ModuleTools\\Module\\ConsumerRuntime", "kind": "type", diff --git a/resources/gate/destination-matrix.md b/resources/gate/destination-matrix.md index cb92806..c414d2a 100644 --- a/resources/gate/destination-matrix.md +++ b/resources/gate/destination-matrix.md @@ -212,6 +212,7 @@ | `Xoops\ModuleTools\Common\ObjectTree::makeSelBoxOptionsArray()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | | `Xoops\ModuleTools\Common\Output` | type | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | | `Xoops\ModuleTools\Common\Output::getEditor()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 0 | `missing` | +| `Xoops\ModuleTools\Common\Output::localPath()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 0 | `missing` | | `Xoops\ModuleTools\Common\Output::metaDescription()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | | `Xoops\ModuleTools\Common\Output::metaKeywords()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | | `Xoops\ModuleTools\Common\Output::selectSorting()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | @@ -351,6 +352,7 @@ | `Xoops\ModuleTools\Constants::REDIRECT_DELAY_SHORT` | constant | retain-compat | not-applicable | `—` | not-applicable | 0 | `missing` | | `Xoops\ModuleTools\Form\ObjectFormBuilder` | type | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | | `Xoops\ModuleTools\Form\ObjectFormBuilder::build()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | +| `Xoops\ModuleTools\Form\ObjectFormBuilder::cancelHandler()` | method | retain-compat (then-migrate) | migrate-on-4.0 | `Xmf\Presentation\DataTable` | experimental | 0 | `missing` | | `Xoops\ModuleTools\Module\ConsumerRuntime` | type | retain-compat (reassess) | not-applicable | `—` | not-applicable | 100 | `missing` | | `Xoops\ModuleTools\Module\ConsumerRuntime::assertReady()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 0 | `missing` | | `Xoops\ModuleTools\Module\ConsumerRuntime::dependencyError()` | method | retain-compat (reassess) | not-applicable | `—` | not-applicable | 99 | `missing` | diff --git a/src/Common/Output.php b/src/Common/Output.php index 6446338..36d01d5 100644 --- a/src/Common/Output.php +++ b/src/Common/Output.php @@ -142,7 +142,7 @@ public static function localPath(string $path, string $fallback = 'index.php'): return self::isLocalPath($fallback) ? $fallback : 'index.php'; } - public static function isLocalPath(string $path): bool + private static function isLocalPath(string $path): bool { return '' !== $path && 1 !== \preg_match('/^\s|[\x00-\x1F\x7F]|\\\\/', $path) From a869b5c7a157d8cdab494a03e55a681f6e4863ed Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 11:18:56 -0400 Subject: [PATCH 13/22] fix: resolve Copilot review round six - VersionChecks::checkVerXoops()/checkVerPhp() accept XoopsModule|false|null so the legacy false from getByDirname() no longer throws a TypeError - ObjectTree: drop the pre-bootstrap placeholder class; loaded once it would shadow the real class for the rest of the process - dependency-review workflow: the Dependency Graph is enabled for the repository, so the step is a hard gate again - allowlist the widened parameter types; drop the obsolete PHPStan baseline entry --- .github/workflows/dependency-review.yml | 11 +-- CHANGELOG.md | 4 + phpstan-baseline.neon | 6 -- resources/gate/api-surface-allowlist.json | 4 +- resources/gate/api-surface.json | 4 +- src/Common/ObjectTree.php | 109 ++++++++++------------ src/Common/VersionChecks.php | 11 ++- 7 files changed, 69 insertions(+), 80 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4871b66..c65e442 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,12 +1,8 @@ name: Dependency Review -# NOTE: actions/dependency-review-action requires the repository's Dependency -# Graph to be enabled (Settings → Code security → Dependency graph). Until that -# is on, the action fails with "Dependency review is not supported on this -# repository". The action step is marked continue-on-error so a disabled graph -# cannot block (or red-flag) PRs: the step may fail while the job still concludes -# successfully. Once Dependency graph is enabled, remove continue-on-error from -# the step to make this a hard gate again. +# actions/dependency-review-action needs the repository's Dependency Graph +# (Settings → Code security → Dependency graph). It is enabled for this +# repository, so the step is a hard gate: a vulnerable dependency change fails CI. on: pull_request: @@ -25,4 +21,3 @@ jobs: - name: Dependency review uses: actions/dependency-review-action@v5 - continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f51026..e56ea3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ The format is based on Keep a Changelog and this project follows Semantic Versio instead of silently returning a partial clone. ### Fixed +- `Common\VersionChecks::checkVerXoops()` / `checkVerPhp()` accept the legacy `false` from + `XoopsModule::getByDirname()` again instead of throwing a `TypeError`. +- `Common\ObjectTree` no longer installs a placeholder class when loaded before XOOPS is + bootstrapped; a placeholder loaded once would have shadowed the real class for the whole process. - `Persistence\PersistableHandler::setGrantedObjectsCriteria()` always adds a key restriction: with no grants or no module context the criteria now match nothing instead of every row. Conditions already in the criteria are grouped first so an OR among them cannot bypass the restriction. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 75e64b5..4867ee1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -564,12 +564,6 @@ parameters: count: 1 path: src/Common/SysUtility.php - - - message: '#^PHPDoc tag @param for parameter \$module with type bool\|XoopsModule\|null is not subtype of native type XoopsModule\|null\.$#' - identifier: parameter.phpDocType - count: 1 - path: src/Common/SysUtility.php - - message: '#^Unsafe usage of new static\(\)\.$#' identifier: new.static diff --git a/resources/gate/api-surface-allowlist.json b/resources/gate/api-surface-allowlist.json index 3ebc281..b631789 100644 --- a/resources/gate/api-surface-allowlist.json +++ b/resources/gate/api-surface-allowlist.json @@ -72,6 +72,8 @@ "symbols/Xoops\\ModuleTools\\Common\\DirectoryChecker/methods/handleRequest:added", "symbols/Xoops\\ModuleTools\\Common\\Output/methods/localPath:added", "symbols/Xoops\\ModuleTools\\Form\\ObjectFormBuilder/methods/cancelHandler:added", - "symbols/Xoops\\ModuleTools\\Persistence\\PersistableHandler/methods/setGrantedObjectsCriteria/documented_false_or_null:value-changed" + "symbols/Xoops\\ModuleTools\\Persistence\\PersistableHandler/methods/setGrantedObjectsCriteria/documented_false_or_null:value-changed", + "symbols/Xoops\\ModuleTools\\Common\\VersionChecks/methods/checkVerPhp/parameters/0/type:value-changed", + "symbols/Xoops\\ModuleTools\\Common\\VersionChecks/methods/checkVerXoops/parameters/0/type:value-changed" ] } diff --git a/resources/gate/api-surface.json b/resources/gate/api-surface.json index 858eef3..c51daa4 100644 --- a/resources/gate/api-surface.json +++ b/resources/gate/api-surface.json @@ -5926,7 +5926,7 @@ "parameters": [ { "name": "module", - "type": "?XoopsModule", + "type": "XoopsModule|false|null", "by_ref": false, "variadic": false, "has_default": true, @@ -5943,7 +5943,7 @@ "parameters": [ { "name": "module", - "type": "?XoopsModule", + "type": "XoopsModule|false|null", "by_ref": false, "variadic": false, "has_default": true, diff --git a/src/Common/ObjectTree.php b/src/Common/ObjectTree.php index 8b031cb..a1f911f 100644 --- a/src/Common/ObjectTree.php +++ b/src/Common/ObjectTree.php @@ -16,75 +16,68 @@ * @author XOOPS Development Team */ -if (!defined('XOOPS_ROOT_PATH')) { - class ObjectTree - { - public function __construct() - { - throw new \RuntimeException('XOOPS must be bootstrapped before ObjectTree can be used.'); - } - } -} else { - if (!\class_exists(\XoopsObjectTree::class, false)) { - require_once \XOOPS_ROOT_PATH . '/class/tree.php'; - } +// Loaded from XOOPS (XOOPS_ROOT_PATH set) or from a harness that stubs \XoopsObjectTree; there is +// deliberately no fallback class, because a fallback loaded once would shadow the real one for the +// rest of the process. +if (!\class_exists(\XoopsObjectTree::class, false) && \defined('XOOPS_ROOT_PATH')) { + require_once \XOOPS_ROOT_PATH . '/class/tree.php'; +} /** * Form element that ... * * @api Stable Common-tier API (Lab\* is experimental, module-local code is private). */ - class ObjectTree extends \XoopsObjectTree +class ObjectTree extends \XoopsObjectTree +{ + /** + * Make options for a select box from + * + * @param string $fieldName Name of the member variable from the node objects that should be used as the title for the options. + * @param int $key ID of the object to display as the root of select options + * @param string|array $optionsArray (reference to a string when called from outside) Result from previous recursions + * @param string $prefix_orig String to indent items at deeper levels + * @param string $prefix_curr String to indent the current item + * + * @return string + */ + public function makeSelBoxOptionsArray($fieldName, $key, &$optionsArray, $prefix_orig, $prefix_curr = '') { - /** - * Make options for a select box from - * - * @param string $fieldName Name of the member variable from the node objects that should be used as the title for the options. - * @param int $key ID of the object to display as the root of select options - * @param string|array $optionsArray (reference to a string when called from outside) Result from previous recursions - * @param string $prefix_orig String to indent items at deeper levels - * @param string $prefix_curr String to indent the current item - * - * @return string - */ - public function makeSelBoxOptionsArray($fieldName, $key, &$optionsArray, $prefix_orig, $prefix_curr = '') - { - if ($key > 0) { - $value = $this->tree[$key]['obj']->getVar($this->myId); - $optionsArray[$value] = $prefix_curr . $this->tree[$key]['obj']->getVar($fieldName); - $prefix_curr .= $prefix_orig; - } - if (isset($this->tree[$key]['child']) && !empty($this->tree[$key]['child'])) { - foreach ($this->tree[$key]['child'] as $childkey) { - $this->makeSelBoxOptionsArray($fieldName, $childkey, $optionsArray, $prefix_orig, $prefix_curr); - } + if ($key > 0) { + $value = $this->tree[$key]['obj']->getVar($this->myId); + $optionsArray[$value] = $prefix_curr . $this->tree[$key]['obj']->getVar($fieldName); + $prefix_curr .= $prefix_orig; + } + if (isset($this->tree[$key]['child']) && !empty($this->tree[$key]['child'])) { + foreach ($this->tree[$key]['child'] as $childkey) { + $this->makeSelBoxOptionsArray($fieldName, $childkey, $optionsArray, $prefix_orig, $prefix_curr); } - - return $optionsArray; } - /** - * Make a select box with options from the tree - * - * @param string $name - * @param string $fieldName Name of the member variable from the node objects that should be used as the title for the options. - * @param string $prefix String to indent deeper levels - * @param string $selected - * @param bool $addEmptyOption Set TRUE to add an empty option with value "0" at the top of the hierarchy - * @param int $key ID of the object to display as the root of select options - * - * @param string $extra - * @return string|array Associative array of value->name pairs, useful for XoopsFormSelect->addOptionArray method - * addOptionArray method - */ - public function makeSelBox($name, $fieldName, $prefix = '-', $selected = '', $addEmptyOption = false, $key = 0, $extra = '') - { - $optionsArray = []; - if ($addEmptyOption) { - $optionsArray[0] = ''; - } + return $optionsArray; + } - return $this->makeSelBoxOptionsArray($fieldName, $key, $optionsArray, $prefix); + /** + * Make a select box with options from the tree + * + * @param string $name + * @param string $fieldName Name of the member variable from the node objects that should be used as the title for the options. + * @param string $prefix String to indent deeper levels + * @param string $selected + * @param bool $addEmptyOption Set TRUE to add an empty option with value "0" at the top of the hierarchy + * @param int $key ID of the object to display as the root of select options + * + * @param string $extra + * @return string|array Associative array of value->name pairs, useful for XoopsFormSelect->addOptionArray method + * addOptionArray method + */ + public function makeSelBox($name, $fieldName, $prefix = '-', $selected = '', $addEmptyOption = false, $key = 0, $extra = '') + { + $optionsArray = []; + if ($addEmptyOption) { + $optionsArray[0] = ''; } + + return $this->makeSelBoxOptionsArray($fieldName, $key, $optionsArray, $prefix); } } diff --git a/src/Common/VersionChecks.php b/src/Common/VersionChecks.php index b5c3612..1a5eb8d 100644 --- a/src/Common/VersionChecks.php +++ b/src/Common/VersionChecks.php @@ -27,12 +27,13 @@ trait VersionChecks * Verifies XOOPS version meets minimum requirements for this module * @static * + * @param \XoopsModule|false|null $module false is the legacy "not found" value from getByDirname() * @param null|string $requiredVer * @return bool true if meets requirements, false if not */ - public static function checkVerXoops(?\XoopsModule $module = null, $requiredVer = null): bool + public static function checkVerXoops(\XoopsModule|false|null $module = null, $requiredVer = null): bool { - $module ??= \XoopsModule::getByDirname(self::consumerDirname()); + $module = $module ?: \XoopsModule::getByDirname(self::consumerDirname()); $moduleDirName = (string)$module->getVar('dirname'); $errorConstant = '_CO_MTOOLS_ERROR_BAD_XOOPS'; \xoops_loadLanguage('admin', $moduleDirName); @@ -56,13 +57,13 @@ public static function checkVerXoops(?\XoopsModule $module = null, $requiredVer /** * Verifies PHP version meets minimum requirements for this module * @static - * @param \XoopsModule|bool|null $module + * @param \XoopsModule|false|null $module false is the legacy "not found" value from getByDirname() * * @return bool true if meets requirements, false if not */ - public static function checkVerPhp(?\XoopsModule $module = null): bool + public static function checkVerPhp(\XoopsModule|false|null $module = null): bool { - $module ??= \XoopsModule::getByDirname(self::consumerDirname()); + $module = $module ?: \XoopsModule::getByDirname(self::consumerDirname()); $moduleDirName = (string)$module->getVar('dirname'); $errorConstant = '_CO_MTOOLS_ERROR_BAD_PHP'; \xoops_loadLanguage('admin', $moduleDirName); From b5c31f169730c38534c775ac69d5a6aadc706673 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 11:57:40 -0400 Subject: [PATCH 14/22] fix: resolve Copilot review round seven - ObjectTree no longer overrides makeSelBox() with an array-returning version; core's HTML contract is inherited and the value => title array is makeOptionsArray() - VersionChecks::checkVerXoops()/checkVerPhp() return false when the module cannot be resolved instead of dereferencing false - allowlist the ObjectTree method change; drop the two obsolete PHPStan baseline entries --- CHANGELOG.md | 5 ++++ phpstan-baseline.neon | 12 -------- resources/gate/api-surface-allowlist.json | 4 ++- resources/gate/api-surface.json | 36 ++++------------------- resources/gate/destination-matrix.json | 2 +- resources/gate/destination-matrix.md | 2 +- src/Common/ObjectTree.php | 23 +++++++-------- src/Common/VersionChecks.php | 6 ++++ 8 files changed, 32 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56ea3f..699d90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ## [Unreleased] ### Changed +- `Common\ObjectTree::makeSelBox()` no longer overrides core: it returns the HTML select that + `XoopsObjectTree` documents. The value => title array it used to return is + `ObjectTree::makeOptionsArray()`. - `Form\ObjectFormBuilder::build()` / `Object\DynamicObject::getForm()`: `$cancelAction` is now a local URL (or `history.back()`), emitted as a fixed `location.href = "..."` assignment. It is no longer executed as a JavaScript statement; a scheme, host or control character falls back to @@ -26,6 +29,8 @@ The format is based on Keep a Changelog and this project follows Semantic Versio instead of silently returning a partial clone. ### Fixed +- `Common\VersionChecks` returns `false` when the module cannot be resolved instead of + dereferencing the `false` from `XoopsModule::getByDirname()`. - `Common\VersionChecks::checkVerXoops()` / `checkVerPhp()` accept the legacy `false` from `XoopsModule::getByDirname()` again instead of throwing a `TypeError`. - `Common\ObjectTree` no longer installs a placeholder class when loaded before XOOPS is diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4867ee1..92eddc4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -462,18 +462,6 @@ parameters: count: 1 path: src/Common/ModuleFeedback.php - - - message: '#^Method Xoops\\ModuleTools\\Common\\ObjectTree\:\:makeSelBox\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Common/ObjectTree.php - - - - message: '#^Method Xoops\\ModuleTools\\Common\\ObjectTree\:\:makeSelBoxOptionsArray\(\) has parameter \$optionsArray with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Common/ObjectTree.php - - message: '#^Method Xoops\\ModuleTools\\Common\\Output\:\:getEditor\(\) has parameter \$options with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/resources/gate/api-surface-allowlist.json b/resources/gate/api-surface-allowlist.json index b631789..b2b55ad 100644 --- a/resources/gate/api-surface-allowlist.json +++ b/resources/gate/api-surface-allowlist.json @@ -74,6 +74,8 @@ "symbols/Xoops\\ModuleTools\\Form\\ObjectFormBuilder/methods/cancelHandler:added", "symbols/Xoops\\ModuleTools\\Persistence\\PersistableHandler/methods/setGrantedObjectsCriteria/documented_false_or_null:value-changed", "symbols/Xoops\\ModuleTools\\Common\\VersionChecks/methods/checkVerPhp/parameters/0/type:value-changed", - "symbols/Xoops\\ModuleTools\\Common\\VersionChecks/methods/checkVerXoops/parameters/0/type:value-changed" + "symbols/Xoops\\ModuleTools\\Common\\VersionChecks/methods/checkVerXoops/parameters/0/type:value-changed", + "symbols/Xoops\\ModuleTools\\Common\\ObjectTree/methods/makeSelBox:removed", + "symbols/Xoops\\ModuleTools\\Common\\ObjectTree/methods/makeOptionsArray:added" ] } diff --git a/resources/gate/api-surface.json b/resources/gate/api-surface.json index c51daa4..09c412b 100644 --- a/resources/gate/api-surface.json +++ b/resources/gate/api-surface.json @@ -3890,21 +3890,13 @@ "constants": [], "properties": [], "methods": { - "makeSelBox": { + "makeOptionsArray": { "static": false, "by_ref": false, "parameters": [ - { - "name": "name", - "type": null, - "by_ref": false, - "variadic": false, - "has_default": false, - "default": null - }, { "name": "fieldName", - "type": null, + "type": "string", "by_ref": false, "variadic": false, "has_default": false, @@ -3912,23 +3904,15 @@ }, { "name": "prefix", - "type": null, + "type": "string", "by_ref": false, "variadic": false, "has_default": true, "default": "'-'" }, - { - "name": "selected", - "type": null, - "by_ref": false, - "variadic": false, - "has_default": true, - "default": "''" - }, { "name": "addEmptyOption", - "type": null, + "type": "bool", "by_ref": false, "variadic": false, "has_default": true, @@ -3936,22 +3920,14 @@ }, { "name": "key", - "type": null, + "type": "int", "by_ref": false, "variadic": false, "has_default": true, "default": "0" - }, - { - "name": "extra", - "type": null, - "by_ref": false, - "variadic": false, - "has_default": true, - "default": "''" } ], - "return_type": null, + "return_type": "array", "throws": [], "documented_false_or_null": false }, diff --git a/resources/gate/destination-matrix.json b/resources/gate/destination-matrix.json index c8be4f1..3ea6cd7 100644 --- a/resources/gate/destination-matrix.json +++ b/resources/gate/destination-matrix.json @@ -9715,7 +9715,7 @@ } }, { - "symbol": "Xoops\\ModuleTools\\Common\\ObjectTree::makeSelBox()", + "symbol": "Xoops\\ModuleTools\\Common\\ObjectTree::makeOptionsArray()", "kind": "method", "disposition": "retain-compat", "qualifier": "", diff --git a/resources/gate/destination-matrix.md b/resources/gate/destination-matrix.md index c414d2a..8b7f77b 100644 --- a/resources/gate/destination-matrix.md +++ b/resources/gate/destination-matrix.md @@ -208,7 +208,7 @@ | `Xoops\ModuleTools\Common\ModuleStats` | type | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 0 | `missing` | | `Xoops\ModuleTools\Common\ModuleStats::getModuleStats()` | method | retain-compat (find-owner) | not-applicable | `—` | not-applicable | 0 | `missing` | | `Xoops\ModuleTools\Common\ObjectTree` | type | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 17 | `missing` | -| `Xoops\ModuleTools\Common\ObjectTree::makeSelBox()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | +| `Xoops\ModuleTools\Common\ObjectTree::makeOptionsArray()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | | `Xoops\ModuleTools\Common\ObjectTree::makeSelBoxOptionsArray()` | method | retain-compat | migrate-on-4.0 | `Xmf\Bridge\HandlerToRepositoryBridge` | stable | 0 | `missing` | | `Xoops\ModuleTools\Common\Output` | type | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 1 | `missing` | | `Xoops\ModuleTools\Common\Output::getEditor()` | method | retain-compat (until-output-modeled) | not-applicable | `—` | not-applicable | 0 | `missing` | diff --git a/src/Common/ObjectTree.php b/src/Common/ObjectTree.php index a1f911f..0d419a9 100644 --- a/src/Common/ObjectTree.php +++ b/src/Common/ObjectTree.php @@ -35,11 +35,11 @@ class ObjectTree extends \XoopsObjectTree * * @param string $fieldName Name of the member variable from the node objects that should be used as the title for the options. * @param int $key ID of the object to display as the root of select options - * @param string|array $optionsArray (reference to a string when called from outside) Result from previous recursions + * @param array $optionsArray Result from previous recursions * @param string $prefix_orig String to indent items at deeper levels * @param string $prefix_curr String to indent the current item * - * @return string + * @return array */ public function makeSelBoxOptionsArray($fieldName, $key, &$optionsArray, $prefix_orig, $prefix_curr = '') { @@ -58,20 +58,17 @@ public function makeSelBoxOptionsArray($fieldName, $key, &$optionsArray, $prefix } /** - * Make a select box with options from the tree + * Value => indented-title pairs for the tree, ready for XoopsFormSelect::addOptionArray(). * - * @param string $name - * @param string $fieldName Name of the member variable from the node objects that should be used as the title for the options. - * @param string $prefix String to indent deeper levels - * @param string $selected - * @param bool $addEmptyOption Set TRUE to add an empty option with value "0" at the top of the hierarchy - * @param int $key ID of the object to display as the root of select options + * The inherited makeSelBox() keeps core's contract and returns HTML. * - * @param string $extra - * @return string|array Associative array of value->name pairs, useful for XoopsFormSelect->addOptionArray method - * addOptionArray method + * @param string $fieldName Member variable of the node objects used as the option title + * @param string $prefix String to indent deeper levels + * @param bool $addEmptyOption Add an empty option with value "0" at the top + * @param int $key ID of the object to use as the root + * @return array */ - public function makeSelBox($name, $fieldName, $prefix = '-', $selected = '', $addEmptyOption = false, $key = 0, $extra = '') + public function makeOptionsArray(string $fieldName, string $prefix = '-', bool $addEmptyOption = false, int $key = 0): array { $optionsArray = []; if ($addEmptyOption) { diff --git a/src/Common/VersionChecks.php b/src/Common/VersionChecks.php index 1a5eb8d..fb6cd80 100644 --- a/src/Common/VersionChecks.php +++ b/src/Common/VersionChecks.php @@ -34,6 +34,9 @@ trait VersionChecks public static function checkVerXoops(\XoopsModule|false|null $module = null, $requiredVer = null): bool { $module = $module ?: \XoopsModule::getByDirname(self::consumerDirname()); + if (!$module instanceof \XoopsModule) { + return false; + } $moduleDirName = (string)$module->getVar('dirname'); $errorConstant = '_CO_MTOOLS_ERROR_BAD_XOOPS'; \xoops_loadLanguage('admin', $moduleDirName); @@ -64,6 +67,9 @@ public static function checkVerXoops(\XoopsModule|false|null $module = null, $re public static function checkVerPhp(\XoopsModule|false|null $module = null): bool { $module = $module ?: \XoopsModule::getByDirname(self::consumerDirname()); + if (!$module instanceof \XoopsModule) { + return false; + } $moduleDirName = (string)$module->getVar('dirname'); $errorConstant = '_CO_MTOOLS_ERROR_BAD_PHP'; \xoops_loadLanguage('admin', $moduleDirName); From 398836c7d0e19d92ecb0aa0c2bdc298c52cd33e8 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 12:33:36 -0400 Subject: [PATCH 15/22] security: resolve Copilot review round eight - TestdataSample::loadData()/saveData()/clearData() require an administrator session and a valid XOOPS token (TestdataButtons::isAuthorizedRequest()), so a consumer's testdata/index.php cannot run them unguarded - Paginator HTML-escapes the generated page links; PHP_SELF and urlOther could break out of the href attribute - Db::enumerate() validates the table name like its siblings; Db::enumValues() parses ENUM/SET column types correctly instead of returning a fragment of the first value --- CHANGELOG.md | 7 ++ resources/gate/api-surface-allowlist.json | 5 +- resources/gate/api-surface.json | 33 +++++++ resources/gate/destination-matrix.json | 100 ++++++++++++++++++++++ resources/gate/destination-matrix.md | 3 + src/Common/Db.php | 21 ++++- src/Common/Paginator.php | 8 +- src/Common/TestdataButtons.php | 18 +++- src/Common/TestdataSample.php | 15 ++++ tests/Common/DbTest.php | 32 +++++++ tests/Common/PaginatorTest.php | 37 ++++++++ 11 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 tests/Common/DbTest.php create mode 100644 tests/Common/PaginatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 699d90f..a08e017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ The format is based on Keep a Changelog and this project follows Semantic Versio instead of silently returning a partial clone. ### Fixed +- `Common\TestdataSample::loadData()` / `saveData()` / `clearData()` require an administrator + session and a valid XOOPS token (`TestdataButtons::isAuthorizedRequest()`); a consumer's + `testdata/index.php` can no longer run them unguarded. +- `Common\Paginator` HTML-escapes the generated page links; `PHP_SELF` and `urlOther` could + break out of the `href` attribute. +- `Common\Db::enumerate()` validates the table name like its siblings and parses ENUM/SET + values correctly (`Db::enumValues()`); it previously returned only a fragment of the first value. - `Common\VersionChecks` returns `false` when the module cannot be resolved instead of dereferencing the `false` from `XoopsModule::getByDirname()`. - `Common\VersionChecks::checkVerXoops()` / `checkVerPhp()` accept the legacy `false` from diff --git a/resources/gate/api-surface-allowlist.json b/resources/gate/api-surface-allowlist.json index b2b55ad..1c84025 100644 --- a/resources/gate/api-surface-allowlist.json +++ b/resources/gate/api-surface-allowlist.json @@ -76,6 +76,9 @@ "symbols/Xoops\\ModuleTools\\Common\\VersionChecks/methods/checkVerPhp/parameters/0/type:value-changed", "symbols/Xoops\\ModuleTools\\Common\\VersionChecks/methods/checkVerXoops/parameters/0/type:value-changed", "symbols/Xoops\\ModuleTools\\Common\\ObjectTree/methods/makeSelBox:removed", - "symbols/Xoops\\ModuleTools\\Common\\ObjectTree/methods/makeOptionsArray:added" + "symbols/Xoops\\ModuleTools\\Common\\ObjectTree/methods/makeOptionsArray:added", + "symbols/Xoops\\ModuleTools\\Common\\Db/methods/enumValues:added", + "symbols/Xoops\\ModuleTools\\Common\\TestdataButtons/methods/checkToken:added", + "symbols/Xoops\\ModuleTools\\Common\\TestdataButtons/methods/isAuthorizedRequest:added" ] } diff --git a/resources/gate/api-surface.json b/resources/gate/api-surface.json index 09c412b..1f44a59 100644 --- a/resources/gate/api-surface.json +++ b/resources/gate/api-surface.json @@ -2268,6 +2268,23 @@ "throws": [], "documented_false_or_null": true }, + "enumValues": { + "static": true, + "by_ref": false, + "parameters": [ + { + "name": "columnType", + "type": "string", + "by_ref": false, + "variadic": false, + "has_default": false, + "default": null + } + ], + "return_type": "array", + "throws": [], + "documented_false_or_null": false + }, "enumerate": { "static": true, "by_ref": false, @@ -5498,6 +5515,14 @@ "constants": [], "properties": [], "methods": { + "checkToken": { + "static": true, + "by_ref": false, + "parameters": [], + "return_type": "bool", + "throws": [], + "documented_false_or_null": false + }, "hideButtons": { "static": true, "by_ref": false, @@ -5515,6 +5540,14 @@ "throws": [], "documented_false_or_null": false }, + "isAuthorizedRequest": { + "static": true, + "by_ref": false, + "parameters": [], + "return_type": "bool", + "throws": [], + "documented_false_or_null": false + }, "loadButtonConfig": { "static": true, "by_ref": false, diff --git a/resources/gate/destination-matrix.json b/resources/gate/destination-matrix.json index 3ea6cd7..42ed603 100644 --- a/resources/gate/destination-matrix.json +++ b/resources/gate/destination-matrix.json @@ -6598,6 +6598,40 @@ ] } }, + { + "symbol": "Xoops\\ModuleTools\\Common\\Db::enumValues()", + "kind": "method", + "disposition": "retain-compat", + "qualifier": "", + "coupling": [ + "xoops-runtime", + "xoopsobject-handler" + ], + "target_package": "xoops/xmf", + "target_symbol": "Xmf\\Repository\\RepositoryInterface", + "target_min_version": "2.0", + "target_owner": "XMF repository maintainers and module domain owners", + "target_status": "stable", + "availability": "migrate-on-4.0", + "migration": [ + "adapter", + "manual-recipe" + ], + "rector_rules": [], + "rector_config_template": "", + "migration_safety": "manual", + "capability_profile": "", + "parity_test": "missing", + "upstream_issue": "", + "introduced_version": "", + "not_before_release": "", + "next_review_release": "", + "notes": "Static database helpers remain compatibility-only; repositories own new persistence.", + "consumers": { + "usage_count": 0, + "modules": [] + } + }, { "symbol": "Xoops\\ModuleTools\\Common\\Db::enumerate()", "kind": "method", @@ -13719,6 +13753,39 @@ ] } }, + { + "symbol": "Xoops\\ModuleTools\\Common\\TestdataButtons::checkToken()", + "kind": "method", + "disposition": "migrate-to-target", + "qualifier": "await-4.0", + "coupling": [ + "xoops-runtime", + "presentation-html" + ], + "target_package": "xoops/xmf", + "target_symbol": "Xmf\\Module\\Testdata", + "target_min_version": "2.0", + "target_owner": "XMF module tooling maintainers", + "target_status": "provisional", + "availability": "migrate-on-4.0", + "migration": [ + "manual-recipe" + ], + "rector_rules": [], + "rector_config_template": "", + "migration_safety": "manual", + "capability_profile": "", + "parity_test": "missing", + "upstream_issue": "", + "introduced_version": "", + "not_before_release": "", + "next_review_release": "", + "notes": "The target is absent from XMF 1.5 and must be proven in the PHP 8.4 tree.", + "consumers": { + "usage_count": 0, + "modules": [] + } + }, { "symbol": "Xoops\\ModuleTools\\Common\\TestdataButtons::hideButtons()", "kind": "method", @@ -13868,6 +13935,39 @@ ] } }, + { + "symbol": "Xoops\\ModuleTools\\Common\\TestdataButtons::isAuthorizedRequest()", + "kind": "method", + "disposition": "migrate-to-target", + "qualifier": "await-4.0", + "coupling": [ + "xoops-runtime", + "presentation-html" + ], + "target_package": "xoops/xmf", + "target_symbol": "Xmf\\Module\\Testdata", + "target_min_version": "2.0", + "target_owner": "XMF module tooling maintainers", + "target_status": "provisional", + "availability": "migrate-on-4.0", + "migration": [ + "manual-recipe" + ], + "rector_rules": [], + "rector_config_template": "", + "migration_safety": "manual", + "capability_profile": "", + "parity_test": "missing", + "upstream_issue": "", + "introduced_version": "", + "not_before_release": "", + "next_review_release": "", + "notes": "The target is absent from XMF 1.5 and must be proven in the PHP 8.4 tree.", + "consumers": { + "usage_count": 0, + "modules": [] + } + }, { "symbol": "Xoops\\ModuleTools\\Common\\TestdataButtons::loadButtonConfig()", "kind": "method", diff --git a/resources/gate/destination-matrix.md b/resources/gate/destination-matrix.md index 8b7f77b..5c738d4 100644 --- a/resources/gate/destination-matrix.md +++ b/resources/gate/destination-matrix.md @@ -125,6 +125,7 @@ | `Xoops\ModuleTools\Common\Db` | type | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 6 | `missing` | | `Xoops\ModuleTools\Common\Db::blockAddCatSelect()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | | `Xoops\ModuleTools\Common\Db::cloneRecord()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 1 | `missing` | +| `Xoops\ModuleTools\Common\Db::enumValues()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | | `Xoops\ModuleTools\Common\Db::enumerate()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | | `Xoops\ModuleTools\Common\Db::fieldExists()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 10 | `missing` | | `Xoops\ModuleTools\Common\Db::queryAndCheck()` | method | retain-compat | migrate-on-4.0 | `Xmf\Repository\RepositoryInterface` | stable | 0 | `missing` | @@ -319,7 +320,9 @@ | `Xoops\ModuleTools\Common\SysUtility::tableExists()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 0 | `missing` | | `Xoops\ModuleTools\Common\SysUtility::truncateHtml()` | method | retain-compat (split-first-per-method) | not-applicable | `—` | not-applicable | 1 | `missing` | | `Xoops\ModuleTools\Common\TestdataButtons` | type | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 119 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons::checkToken()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | | `Xoops\ModuleTools\Common\TestdataButtons::hideButtons()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 119 | `missing` | +| `Xoops\ModuleTools\Common\TestdataButtons::isAuthorizedRequest()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | | `Xoops\ModuleTools\Common\TestdataButtons::loadButtonConfig()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 119 | `missing` | | `Xoops\ModuleTools\Common\TestdataButtons::renderHtml()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | | `Xoops\ModuleTools\Common\TestdataButtons::setSampleButtonConfig()` | method | migrate-to-target (await-4.0) | migrate-on-4.0 | `Xmf\Module\Testdata` | provisional | 0 | `missing` | diff --git a/src/Common/Db.php b/src/Common/Db.php index a556e4f..870a6e9 100644 --- a/src/Common/Db.php +++ b/src/Common/Db.php @@ -142,7 +142,7 @@ public static function cloneRecord(\XoopsMySQLDatabase $db, $tableName, string $ */ public static function enumerate(\XoopsMySQLDatabase $db, string $tableName, string $columnName): array { - if (!preg_match('/^[A-Za-z0-9_]+$/', $columnName)) { + if (!preg_match('/^[A-Za-z0-9_]+$/', $tableName) || !preg_match('/^[A-Za-z0-9_]+$/', $columnName)) { return []; } @@ -161,9 +161,24 @@ public static function enumerate(\XoopsMySQLDatabase $db, string $tableName, str if (false === $row) { return []; } - $enumList = \explode(',', \str_replace("'", '', \mb_substr($row['COLUMN_TYPE'], 5, -6))); - return $enumList; + return self::enumValues((string) $row['COLUMN_TYPE']); + } + + /** + * The allowed values of an INFORMATION_SCHEMA COLUMN_TYPE such as enum('a','b') or set('x','y'). + * A doubled quote inside a value is unescaped; anything that is not an ENUM/SET yields []. + * + * @return list + */ + public static function enumValues(string $columnType): array + { + if (!preg_match('/^(?:enum|set)\((.*)\)$/is', trim($columnType), $match)) { + return []; + } + preg_match_all("/'((?:[^']|'')*)'/", $match[1], $values); + + return array_map(static fn (string $value): string => str_replace("''", "'", $value), $values[1]); } /** diff --git a/src/Common/Paginator.php b/src/Common/Paginator.php index 8e7ea5f..9b6ddf9 100644 --- a/src/Common/Paginator.php +++ b/src/Common/Paginator.php @@ -252,9 +252,15 @@ public function makeBar(): array ]; } + /** + * Attribute-safe href. toPage (PHP_SELF by default) and urlOther are caller/request + * input; queryStr is already entity-encoded, so encode without double-encoding. + */ private function pageHref(int $page, string $loadtime): string { - return $this->toPage . $this->queryStr . $this->glue . $this->urlPage . '=' . $page . $loadtime; + $href = $this->toPage . $this->queryStr . $this->glue . $this->urlPage . '=' . $page . $loadtime; + + return \htmlspecialchars($href, \ENT_QUOTES, 'UTF-8', false); } private function linkItem(int $page, string $loadtime, string $glyph, string $label): string diff --git a/src/Common/TestdataButtons.php b/src/Common/TestdataButtons.php index ea64a99..57bd759 100644 --- a/src/Common/TestdataButtons.php +++ b/src/Common/TestdataButtons.php @@ -232,13 +232,29 @@ public static function setSampleButtonConfig(\Xmf\Module\Helper $modhelper, int * GET, so it validates the one-time token carried on the GET toggle links produced by * loadButtonConfig(). */ - private static function checkToken(): bool + public static function checkToken(): bool { $security = self::runtimeSecurity(); return is_object($security) && $security->check(); } + /** + * Whether the current request may run a destructive sample-data action: an + * administrator session and a valid one-time token. Used by every state-changing + * TestdataSample method, so a consumer's testdata/index.php cannot skip it. + */ + public static function isAuthorizedRequest(): bool + { + return self::runtimeUserIsAdmin() && self::checkToken(); + } + + /** @legacy-global-accessor */ + private static function runtimeUserIsAdmin(): bool + { + return !empty($GLOBALS['xoopsUserIsAdmin']); + } + /** @legacy-global-accessor */ private static function runtimeSecurity(): mixed { diff --git a/src/Common/TestdataSample.php b/src/Common/TestdataSample.php index 7abc673..6184819 100644 --- a/src/Common/TestdataSample.php +++ b/src/Common/TestdataSample.php @@ -52,6 +52,7 @@ public function __construct(public $modHelper) public function loadData(): void { + $this->assertAuthorized(); $utility = new Utility(); $configurator = new Configurator($this->modHelper->path()); $tables = $this->modHelper->getModule()->getInfo('tables'); @@ -83,6 +84,7 @@ public function loadData(): void public function saveData(): void { + $this->assertAuthorized(); global $xoopsConfig; $tables = $this->modHelper->getModule()->getInfo('tables'); @@ -123,6 +125,7 @@ public function exportSchema(): void public function clearData(): void { + $this->assertAuthorized(); // Load language files $this->modHelper->loadLanguage('common'); $tables = $this->modHelper->getModule()->getInfo('tables'); @@ -133,6 +136,18 @@ public function clearData(): void \redirect_header($this->modHelper->url('admin/index.php'), 1, \constant('_CO_' . $this->moduleDirNameUpper . '_' . 'CLEAR_SAMPLEDATA_OK')); } + /** + * Every state-changing action needs an administrator session and the one-time + * token that TestdataButtons puts on the links; a consumer's testdata/index.php + * dispatch cannot bypass it. redirect_header() exits. + */ + private function assertAuthorized(): void + { + if (!TestdataButtons::isAuthorizedRequest()) { + \redirect_header($this->modHelper->url('admin/index.php'), 3, \defined('_NOPERM') ? \_NOPERM : 'Permission denied.'); + } + } + /** * loadTableFromArrayWithReplace * diff --git a/tests/Common/DbTest.php b/tests/Common/DbTest.php new file mode 100644 index 0000000..b2556a8 --- /dev/null +++ b/tests/Common/DbTest.php @@ -0,0 +1,32 @@ +'; + $_SERVER['QUERY_STRING'] = 'cat=3&q=a%26b'; + $_GET['g2p'] = '1'; + + $paginator = new Paginator(50, 10, 5); + $paginator->setUrlOther("#top' onmouseover='x"); + $paginator->init(); + + $center = $paginator->makeBootStrapBar()['center']; + + self::assertStringNotContainsString('">'; - $_SERVER['QUERY_STRING'] = 'cat=3&q=a%26b'; + $_SERVER['QUERY_STRING'] = 'cat=3&q=a%26b&tag[]=x%23y&f[k]=v+w'; $_GET['g2p'] = '1'; $paginator = new Paginator(50, 10, 5); @@ -32,6 +32,7 @@ public function pageLinksEscapeThePathAndSuffixWithoutDoubleEncodingTheQuery(): self::assertStringNotContainsString('">