diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 804f3ac..afd5264 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,7 +1,7 @@ # language: php build: environment: - php: 8.1.0 + php: 8.2 nodes: tests: true analysis: @@ -9,8 +9,8 @@ build: after: # The following installs the most recent XOOPS CMS version, you might want # to install a specific release tag or branch instead. - - git clone --depth=1 https://github.com/XOOPS/XoopsCore25 - - git clone --depth=1 https://github.com/mambax7/tag + - git clone --depth=1 https://github.com/XOOPS/XoopsCore27 + # - git clone --depth=1 https://github.com/mambax7/tag project_setup: override: true tests: @@ -18,6 +18,6 @@ build: - php-scrutinizer-run --enable-security-analysis filter: dependency_paths: - - XoopsCore25/* + - XoopsCore27/* - tag/* diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c8786..7c2d2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,49 @@ Baseline comparison for **1.6.0**: previous public tree `1.5.1-Final` --- +## [1.7.0 Alpha 1] — 2026-07-30 + +### Added + +- Structured reporting package with typed log fragments, events, severities, + outcomes, operation results, a legacy adapter, and a safe HTML renderer +- `ModuleActionResult::events()`, `messageText()`, and `toOperationResult()` for + HTML-independent report consumption +- `ModuleActionService::operationResult()` and `operationResults()` for callers + adopting `Report\ModuleOperationResult` +- 30 translatable admin-report strings with safe English fallbacks + +### Changed + +- Install-action reports are now structured data that can be rendered to HTML or + consumed as plain text, CLI output, logs, or JSON +- `ModuleActionResult` is deprecated in favor of `Report\ModuleOperationResult`; + existing `runOne()` and `runMany()` callers remain supported through XOOPS 2.8 +- RTL output now isolates counters, versions, statuses, folders, and plan summaries +- Admin report layout uses logical CSS properties, removes module-owned inline + styles, improves long-log scrolling, and avoids duplicate screen-reader labels +- Expanded tests (109 tests / 264 assertions) for hostile log input, + structured-result contracts, translation fallbacks, module-logo path rejection, + and UTF-8 boundary handling + +### Fixed + +- Invalid UTF-8 caused by byte-based NBSP trimming +- Duplicate failure transcripts in structured results +- Blank pages caused by exceptions while loading a module manifest after an action +- Fatal or unusable admin output from missing, blank, non-scalar, or malformed + language strings +- Incorrect protected-module action wording + +### Security + +- Replaced direct install-log markup rendering with an escape-once parser that emits + only fixed module-owned markup and drops executable or raw-text element bodies +- Centralized module-logo URL validation and rejection of remote, missing, absolute, + traversal, and otherwise unsafe paths + +--- + ## [1.6.0] — 2026-07-23 ### Added diff --git a/README.md b/README.md index 48a598f..c0a903d 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@ uninstalling). | | | |---|---| -| **Current version** | 1.6.0 Stable (2026-07-23) | +| **Current version** | 1.7.0 Alpha 1 (2026-07-30) | | **Previous baseline** | 1.5.1 Final (2022-12-15) | | **License** | GNU GPL 2.0 | | **Author** | Michael Beck (Mamba) | -Full history: [CHANGELOG.md](CHANGELOG.md) · [docs/changelog.txt](docs/changelog.txt) +Full history: [CHANGELOG.md](CHANGELOG.md) · [docs/changelog.txt](docs/changelog.txt) · [docs/lang_diff.txt](docs/lang_diff.txt) How-to: [docs/TUTORIAL.md](docs/TUTORIAL.md) --- diff --git a/admin/admin_footer.php b/admin/admin_footer.php index 603e0d2..30875c6 100644 --- a/admin/admin_footer.php +++ b/admin/admin_footer.php @@ -18,6 +18,6 @@ $pathIcon32 = Admin::iconUrl('', '32'); //$pathIcon32 = Xmf\Module\Admin::iconUrl('', '32'); -echo "
\n" . "
\n" . " XOOPS\n" . "
\n" . ' ' . _AM_MODULEADMIN_ADMIN_FOOTER . "\n" . '
'; +echo "
\n" . " \n" . ' ' . _AM_MODULEADMIN_ADMIN_FOOTER . "\n" . '
'; xoops_cp_footer(); diff --git a/admin/index.php b/admin/index.php index a342f31..f0620c2 100644 --- a/admin/index.php +++ b/admin/index.php @@ -71,12 +71,15 @@ echo '

' . _AM_MODULEINSTALLER_DASH_SETS . '

'; echo '
' . \count($sets) . '
'; -echo '

' . \sprintf(_AM_MODULEINSTALLER_DASH_SETS_COUNT, \count($sets)) . '

'; +// Isolate the rendered sentence, NOT the numeral: a %d placeholder casts its +// argument to int, so markup passed into it is silently discarded. Without the +// isolate, '%d saved set(s)' renders as "saved set(s) 0" in an RTL admin. +echo '

' . \sprintf(_AM_MODULEINSTALLER_DASH_SETS_COUNT, \count($sets)) . '

'; echo '

' . _AM_MODULEINSTALLER_DASH_GO_SETS . '

'; echo '

' . _AM_MODULEINSTALLER_DASH_UPDATES . '

'; echo '
' . \count($needsUpdate) . '
'; -echo '

' . \sprintf(_AM_MODULEINSTALLER_DASH_UPDATES_COUNT, \count($needsUpdate)) . '

'; +echo '

' . \sprintf(_AM_MODULEINSTALLER_DASH_UPDATES_COUNT, \count($needsUpdate)) . '

'; echo '

' . _AM_MODULEINSTALLER_DASH_GO_UPDATE . '

'; echo '

' . _AM_MODULEINSTALLER_DASH_ACTIVE . '

'; diff --git a/admin/sets.php b/admin/sets.php index 01da1fe..3f826a5 100644 --- a/admin/sets.php +++ b/admin/sets.php @@ -241,7 +241,7 @@ function renderSetList(ModuleSetRepository $repository, ModuleSetResolver $resol $adminObject->addItemButton(_AM_MODULEINSTALLER_SET_FROM_ACTIVE, 'javascript:document.getElementById("form-from-active").submit();', 'button_ok'); $adminObject->displayButton('left'); - echo '
'; echo ''; - echo ''; + echo '

' . _AM_MODULEINSTALLER_SET_FILTER_NONE . '

'; $onDisk = $catalog->listOnDisk(); // Include missing members so user can uncheck/remove them @@ -418,8 +418,9 @@ function renderSetEditForm( $inSet = isset($selectedMap[$dirname]); $checked = $inSet ? ' checked' : ''; $rowClass = $class . ' set-mod-row' . ($inSet ? ' installer-row-selected' : ''); - $cellBg = $inSet ? 'background-color:var(--installer-selected,#E6EFC2);' : ''; - $rowStyle = $inSet ? ' style="background-color:var(--installer-selected,#E6EFC2);"' : ''; + // Row and cell colour come from .installer-row-selected in admin.css, which + // declares them with !important for tr and for tr > td alike, so the inline + // copies that used to be built here were redundant AND already overridden. $label = null !== $info ? \htmlspecialchars($info['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ' (/' . \htmlspecialchars($dirname, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ')' @@ -429,7 +430,12 @@ function renderSetEditForm( $img = ''; if (null !== $info && '' !== $info['image']) { - $img = ''; + // One logo authority for the whole module: AdminBulkPage::moduleLogoUrl() + // proves the manifest's image is a real file inside the module directory. + $logoUrl = \XoopsModules\Moduleinstaller\AdminBulkPage::moduleLogoUrl((string) $info['dirname'], $info); + $img = null === $logoUrl + ? '' + : ''; } $dirnameEsc = \htmlspecialchars($dirname, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); @@ -438,16 +444,22 @@ function renderSetEditForm( ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); - $tdStyle = $cellBg !== '' ? ' style="' . $cellBg . '"' : ''; - echo ''; - echo '' . $img . ''; - echo '' . $label; + echo ''; + // _AM_MODULEINSTALLER_TOGGLE_SELECTION already exists and is used by + // AdminBulkPage; these two were the last places still hardcoding it. + $toggleTitle = \htmlspecialchars( + \XoopsModules\Moduleinstaller\Lang::text('_AM_MODULEINSTALLER_TOGGLE_SELECTION', 'Toggle selection'), + ENT_QUOTES | ENT_SUBSTITUTE, + 'UTF-8' + ); + echo '' . $img . ''; + echo '' . $label; if (null !== $info && '' !== $info['description']) { echo '
' . \htmlspecialchars($info['description'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; } echo ''; - echo '' . $statusLabel . ''; - echo ''; + echo '' . $statusLabel . ''; + echo ''; echo ''; } echo ''; @@ -621,14 +633,18 @@ function renderApplyForm( echo "
" . _AM_MODULEINSTALLER_SET_PREVIEW_EMPTY . '
'; } else { $counts = installer_plan_counts($plan); - echo '
' . \sprintf( + // Five numerals separated by '·' in one sentence: the worst bidi case in the + // module. The counts cannot be wrapped individually — the format string uses + // %1$d..%5$d and a numeric placeholder casts its argument to int, discarding + // any markup — so the rendered sentence is isolated as a unit instead. + echo '
' . \sprintf( _AM_MODULEINSTALLER_SET_PLAN_SUMMARY, $counts['activate'], $counts['deactivate'], $counts['install'], $counts['uninstall'], $counts['other'] - ) . '
'; + ) . '
'; echo ''; echo ''; echo ''; diff --git a/assets/css/admin.css b/assets/css/admin.css index 59ada28..9272895 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -14,7 +14,14 @@ --installer-badge-inactive: #6c757d; --installer-badge-missing: #dc3545; --installer-badge-protected: #0d6efd; - --installer-badge-notinst: #fd7e14; + /* Darker than the Bootstrap orange this used to be (#fd7e14). That value is a + FILL colour: it carries white badge text at 2.4:1 and, since + .installer-log-warning draws warning transcript text in it, normal-size body + text at 2.6:1 — both under the 4.5:1 AA floor. #b35309 clears it at 5.05:1 in + both directions. The var() fallbacks further down already named this colour; + they never applied, because a fallback is only used when the property is + UNDEFINED, and this :root block always defines it. */ + --installer-badge-notinst: #b35309; } /* @@ -74,7 +81,7 @@ table.outer.module tr.installer-row-selected > td { .installer-apply-stack .installer-apply-action label { font-weight: bold; - margin-right: 0.5em; + margin-inline-end: 0.5em; } .installer-apply-stack .installer-apply-count { @@ -95,9 +102,9 @@ table.outer.module tr.installer-row-selected > td { } .installer-form-actions { - margin-top: 1.25em; - padding-top: 0.75em; - text-align: right; + margin-block-start: 1.25em; + padding-block-start: 0.75em; + text-align: end; clear: both; } @@ -105,14 +112,102 @@ table.outer.module tr.installer-row-selected > td { margin-top: 1.25em; } +/* No max-height/overflow: the report must scroll with the PAGE, not inside a + 28em cage that hides the tail of a long install log. For very large batch runs + collapse each row's transcript behind a
instead of capping height. */ .installer-result-log { list-style: none; margin: 0.75em 0; padding: 0.5em 0.75em; border: 1px solid #d0d0d0; border-radius: 6px; - max-height: 28em; - overflow: auto; +} + +/* Logo column first, matching the DOM order AdminBulkPage now emits. */ +.installer-result-log .installer-result-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 1em; + align-items: start; + margin: 0; + padding: 0.9em 0.75em; + border-block-end: 1px solid #ddd; + border-inline-start: 4px solid #6c757d; + background-image: none; +} + +.installer-result-log .installer-result-item:last-child { + border-block-end: 0; +} + +.installer-result-log .installer-result-item.success { + border-inline-start-color: var(--installer-badge-active); +} + +.installer-result-log .installer-result-item.warning { + border-inline-start-color: var(--installer-badge-notinst); +} + +.installer-result-log .installer-result-item.error { + border-inline-start-color: var(--installer-badge-missing); +} + +.installer-result-body { + min-width: 0; +} + +.installer-result-label { + margin-block-end: 0.5em; + font-size: 1.05em; +} + +.installer-result-dirname { + color: #666; + font-size: 0.9em; +} + +/* The install transcript. Monospace because core's   indentation only lines + up in a fixed-pitch font; text-align is logical (start), never left, so the + report reads correctly in an RTL admin language. */ +.installer-result-message { + text-align: start; + line-height: 1.45; + font-family: Consolas, "Courier New", monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +/* The module's severity palette. ModuleActionResult emits these instead of core's + inline color:#ff0000, so the colours live here and can be re-themed, dark-moded + or overridden by a site stylesheet — none of which is possible with a style + attribute, and all three are blocked outright by a strict style-src CSP. + + Deliberately NOT scoped under .installer-result-message: they were, and that made + them unusable anywhere else, which is why two inline colours survived elsewhere in + the module. All three severities LogSeverity can emit are styled — a class the + renderer can produce with no rule behind it is a silent styling failure the first + time something constructs it. LogSeverity::Info emits no class, so it needs none. */ +.installer-log-error { + color: var(--installer-badge-missing, #c0392b); + font-weight: 600; +} + +.installer-log-warning { + color: var(--installer-badge-notinst, #b35309); + font-weight: 600; +} + +.installer-log-success { + color: var(--installer-badge-active, #14713b); + font-weight: 600; +} + +.installer-result-logo { + width: auto; + height: auto; + max-width: 96px; + max-height: 96px; + object-fit: contain; } /* Filter + list chrome (module-scoped only) */ @@ -125,7 +220,7 @@ table.outer.module tr.installer-row-selected > td { font-weight: 700; font-style: italic; color: #333; - margin-left: 0.15em; + margin-inline-start: 0.15em; } /* Count next to page title — light accent, does not restyle .CPbigTitle itself */ @@ -135,7 +230,7 @@ table.outer.module tr.installer-row-selected > td { font-weight: 600; font-style: normal; color: #555; - margin-left: 0.35em; + margin-inline-start: 0.35em; white-space: nowrap; } @@ -188,7 +283,7 @@ table.outer.module tr.installer-row-selected > td { } .installer-set-toolbar-hint { - margin-left: 0.15em; + margin-inline-start: 0.15em; color: #495057; } @@ -203,7 +298,7 @@ table.outer.module tr.installer-row-selected > td { .installer-filter-label { font-weight: bold; - margin-right: 0.25em; + margin-inline-end: 0.25em; } .installer-module-filter, @@ -326,7 +421,7 @@ table.module td.yesno, min-width: 7.5em !important; white-space: nowrap !important; vertical-align: middle !important; - text-align: left !important; + text-align: start !important; overflow: hidden; } @@ -470,12 +565,12 @@ td.installer-yesno-cell { margin: 0.5em 0 1em 0; padding: 0.6em 0.85em; background: #e7f1ff; - border-left: 4px solid #0d6efd; + border-inline-start: 4px solid #0d6efd; } .installer-dash-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(14em, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(14em, 1fr)); gap: 1em; margin: 1em 0; } @@ -499,8 +594,8 @@ td.installer-yesno-cell { .installer-tip { cursor: help; - border-bottom: 1px dotted #666; - margin-left: 0.25em; + border-block-end: 1px dotted #666; + margin-inline-start: 0.25em; color: #0d6efd; font-weight: bold; } @@ -612,3 +707,19 @@ input.installer-set-input { max-height: 52px; } } + +/* + * Initial-state utilities, so markup carries no style attribute. + * + * xo-installer.js toggles these elements by writing element.style.display, which is + * an inline style and therefore beats this class — so the class decides only the + * state the page is SERVED in, which is the part a strict style-src 'self' policy + * would otherwise block. + */ +.installer-hidden { + display: none; +} + +.installer-footer-center { + text-align: center; +} diff --git a/class/AdminBulkPage.php b/class/AdminBulkPage.php index 537b39f..42a85b0 100644 --- a/class/AdminBulkPage.php +++ b/class/AdminBulkPage.php @@ -25,8 +25,14 @@ */ final class AdminBulkPage { - /** Cache-bust query for admin CSS/JS (bump when assets change). */ - public const ASSET_VERSION = '1709'; + /** + * Cache-bust query for admin CSS/JS. + * + * Bump this in the SAME commit as any CSS/JS change: an admin holding the + * previous stylesheet otherwise sees none of it, which is how a CSS-only + * improvement can measure as no improvement at all. + */ + public const ASSET_VERSION = '1715'; /** * Full bulk page after xoops_cp_header() — navigation, list or report, form, assets. @@ -46,10 +52,8 @@ public static function serve(string $action): void if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET')) { if (! self::checkCsrf()) { $pageHasForm = false; - $msg = \defined('_AM_MODULEINSTALLER_ERR_TOKEN') - ? \_AM_MODULEINSTALLER_ERR_TOKEN - : 'Invalid security token. Please try again.'; - $content = "
" . \htmlspecialchars((string) $msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
'; + $msg = Lang::text('_AM_MODULEINSTALLER_ERR_TOKEN', 'Invalid security token. Please try again.'); + $content = "
" . \htmlspecialchars($msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
'; } else { $handled = self::handlePost($action, self::successTitleFor($action)); $pageHasForm = $handled['pageHasForm']; @@ -68,7 +72,7 @@ public static function serve(string $action): void echo $content; echo ''; + // Was a raw constant reference. On PHP 8 an undefined constant is a fatal + // Error, so a partial language pack turned this page blank. + $content .= "
" + . \htmlspecialchars( + Lang::text('_AM_MODULEINSTALLER_PRESELECTED_UPDATES', 'Pre-selected modules whose version on disk differs from the database.'), + \ENT_QUOTES | \ENT_SUBSTITUTE, + 'UTF-8' + ) . '
'; } $toggleUrl = 'update.php' . ($onlyNeeds ? '' : '?needs_only=1'); - $toggleLbl = \_AM_MODULEINSTALLER_SHOW_UPDATES_ONLY; + $toggleLbl = Lang::text('_AM_MODULEINSTALLER_SHOW_UPDATES_ONLY', 'Show only modules that need update'); $content .= '

' . \htmlspecialchars($toggleLbl, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ($onlyNeeds ? ' ✓' : '') . '

'; @@ -166,11 +177,9 @@ private static function buildUpdateListPage(ModuleCatalog $catalog): array private static function emptyListMessage(): string { - $msg = \defined('_AM_MODULEINSTALLER_NO_MODULES') - ? \_AM_MODULEINSTALLER_NO_MODULES - : 'No modules found.'; + $msg = Lang::text('_AM_MODULEINSTALLER_NO_MODULES', 'No modules found.'); - return "
" . \htmlspecialchars((string) $msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
'; + return "
" . \htmlspecialchars($msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
'; } private static function successTitleFor(string $action): string @@ -183,11 +192,9 @@ private static function successTitleFor(string $action): string ModuleActionService::ACTION_UPDATE => '_AM_MODULEINSTALLER_DONE_UPDATE', ]; $const = $map[$action] ?? ''; - if ('' !== $const && \defined($const)) { - return (string) \constant($const); - } + $fallback = \ucfirst($action) . ' complete'; - return \ucfirst($action) . ' complete'; + return '' === $const ? $fallback : Lang::text($const, $fallback); } private static function checkCsrf(): bool @@ -205,7 +212,7 @@ private static function wrapBulkForm(string $innerHtml, string $actionScript): s { $actionEsc = \htmlspecialchars($actionScript, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); $submitLbl = \htmlspecialchars( - \defined('_AM_MODULEINSTALLER_SUBMIT') ? \_AM_MODULEINSTALLER_SUBMIT : 'Continue', + Lang::text('_AM_MODULEINSTALLER_SUBMIT', 'Continue'), \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8' ); @@ -245,18 +252,18 @@ public static function renderModuleTable(array $dirnames, array $preselect = [], { $catalog = new ModuleCatalog(); $preselect = \array_fill_keys($preselect, true); - $filterPh = \defined('_AM_MODULEINSTALLER_FILTER_PLACEHOLDER') ? \_AM_MODULEINSTALLER_FILTER_PLACEHOLDER : 'Filter…'; - $filterLbl = \defined('_AM_MODULEINSTALLER_FILTER') ? \_AM_MODULEINSTALLER_FILTER : 'Filter'; - $filterHint = \defined('_AM_MODULEINSTALLER_FILTER_HINT') ? \_AM_MODULEINSTALLER_FILTER_HINT : ''; - $noneMsg = \defined('_AM_MODULEINSTALLER_SET_FILTER_NONE') ? \_AM_MODULEINSTALLER_SET_FILTER_NONE : 'No match'; + $filterPh = Lang::text('_AM_MODULEINSTALLER_FILTER_PLACEHOLDER', 'Filter…'); + $filterLbl = Lang::text('_AM_MODULEINSTALLER_FILTER', 'Filter'); + $filterHint = Lang::text('_AM_MODULEINSTALLER_FILTER_HINT', ''); + $noneMsg = Lang::text('_AM_MODULEINSTALLER_SET_FILTER_NONE', 'No match'); $selectAllLbl = \htmlspecialchars( - \defined('_AM_MODULEINSTALLER_SELECT_ALL') ? \_AM_MODULEINSTALLER_SELECT_ALL : 'Select All', + Lang::text('_AM_MODULEINSTALLER_SELECT_ALL', 'Select All'), \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8' ); $selectNoneLbl = \htmlspecialchars( - \defined('_AM_MODULEINSTALLER_SELECT_NONE') ? \_AM_MODULEINSTALLER_SELECT_NONE : 'Un-Select All', + Lang::text('_AM_MODULEINSTALLER_SELECT_NONE', 'Un-Select All'), \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8' ); @@ -269,19 +276,19 @@ public static function renderModuleTable(array $dirnames, array $preselect = [], // Selected count is shown on the page title (.CPbigTitle) and sticky bar only. $content = '
'; $content .= ' '; + . \htmlspecialchars($filterLbl, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ' '; // type=text (not search) so theme/input styles apply; size gives a visible fallback width $content .= ''; + . 'placeholder="' . \htmlspecialchars($filterPh, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '" ' + . 'autocomplete="off" aria-label="' . \htmlspecialchars($filterLbl, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '">'; if ('' !== $filterHint) { $content .= ' ' - . \htmlspecialchars((string) $filterHint, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ''; + . \htmlspecialchars($filterHint, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ''; } $content .= '
'; - $content .= ''; + $content .= '

' + . \htmlspecialchars($noneMsg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '

'; // No ul/li wrapper: list markers broke first-row radio layout (black square + stretched Yes/No) $content .= "
" . "
' . _AM_MODULEINSTALLER_SET_MODULE . '' . _AM_MODULEINSTALLER_SET_ACTION . '' . _AM_MODULEINSTALLER_SET_REASON . '
\n" @@ -289,6 +296,13 @@ public static function renderModuleTable(array $dirnames, array $preselect = [], $count = 0; $even = false; + // Row-invariant: the same lookup and the same escape for every row. + $toggleTitle = \htmlspecialchars( + Lang::text('_AM_MODULEINSTALLER_TOGGLE_SELECTION', 'Toggle selection'), + \ENT_QUOTES | \ENT_SUBSTITUTE, + 'UTF-8' + ); + foreach ($dirnames as $file) { $file = \trim((string) $file); if ('' === $file) { @@ -309,13 +323,25 @@ public static function renderModuleTable(array $dirnames, array $preselect = [], } } - $spanOpen = $highlight ? "" : ''; + // "needs update" is a warning, not an error, and it is a severity — so it + // uses the same palette class the transcript does rather than an inline + // colour that no theme or stylesheet can override. + $spanOpen = $highlight ? '' : ''; $spanClose = ''; - $imgSrc = \XOOPS_URL . '/modules/' . $info['dirname'] . '/' . $info['image']; + // Same resolver as the report: proves the target is a local file inside + // the module before building a URL from a manifest value. Concatenating + // $info['image'] straight onto XOOPS_URL — which this line used to do — + // escapes correctly but validates nothing, so two code paths disagreed + // about how much the manifest is trusted. + $imgSrc = self::moduleLogoUrl($file, $info) ?? ''; $dirnameEsc = \htmlspecialchars($file, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); $nameEsc = \htmlspecialchars($info['name'], \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); - $toggleJs = "toggleModuleRow('" . $dirnameEsc . "')"; + + // The dirname lands in two contexts and needs two escapers: + // htmlspecialchars() for id='…', and jsCall() for the handler — see there + // for why HTML escaping is the wrong tool inside an inline handler. + $toggleJs = self::jsCall('toggleModuleRow', $file); $searchBlob = \htmlspecialchars( \mb_strtolower($info['name'] . ' ' . $file . ' ' . $info['description']), \ENT_QUOTES | \ENT_SUBSTITUTE, @@ -324,16 +350,28 @@ public static function renderModuleTable(array $dirnames, array $preselect = [], $even = ! $even; $stripe = $even ? 'even' : 'odd'; $rowClass = \trim($stripe . ($value !== 0 ? ' installer-row-selected' : '')); - $content .= "\n"; - $content .= " \n"; - $content .= " \n"; + $content .= " \n"; + $content .= " \n"; @@ -347,18 +385,76 @@ public static function renderModuleTable(array $dirnames, array $preselect = [], return 0 === $count ? self::emptyListMessage() : $content; } + /** + * Escaped text that goes before and after the live selection counter. + * + * Returns DATA, not markup, and takes no markup: an earlier version accepted the + * counter element as a string parameter and concatenated it verbatim, which made + * a public method whose second argument is trusted HTML — a trap for any caller + * that passes something user-derived. The counter element is built by the one + * caller that owns it; this method only decides where the text splits. + * + * Pure and public so it can be tested directly: the count itself is written by + * JavaScript, so none of this is reachable through a rendered page. + * + * Three things the naive split gets wrong, all of them translator-facing: + * - '%1$d selected' and '%s ausgewählt' are valid translations of a '%d' + * string, and explode('%d') matches neither; + * - '%%' is a LITERAL percent, not a placeholder, so '50%% of %d' must split + * at the second construct and render "50% of"; + * - this string takes exactly ONE argument, so a translation carrying a second + * placeholder is a mistake — and showing an admin a raw '%2$d' is worse than + * dropping it. Dropping it takes the space in front of it too, or + * '%1$d: %2$s selected' would render ': selected' with a doubled gap. + * + * @param string $template language string containing one placeholder + * + * @return array{0:string, 1:string} escaped prefix and suffix + */ + public static function selectedCountParts(string $template): array + { + // (*SKIP)(*FAIL) makes the engine step over '%%' without matching it. + $placeholder = '/%%(*SKIP)(*FAIL)|%(?:\d+\$)?[dsu]/'; + // The same construct, plus the single space that would otherwise be orphaned + // when the whole placeholder is removed rather than substituted. + $strayPlaceholder = '/[ \t]?(?:%%(*SKIP)(*FAIL)|%(?:\d+\$)?[dsu])/'; + + $parts = \preg_split($placeholder, $template, 2); + if (false === $parts || 1 === \count($parts)) { + // No recognisable placeholder: keep the text and put the counter first, + // rather than dropping either of them. + $parts = ['', '' === $template ? '' : ' ' . $template]; + } + + $clean = static function (string $text) use ($strayPlaceholder): string { + // Any FURTHER placeholder belongs to no argument; drop it, then collapse + // the escaped percents that survived the split. + $text = (string) \preg_replace($strayPlaceholder, '', $text); + + return \str_replace('%%', '%', $text); + }; + + return [ + \htmlspecialchars($clean($parts[0]), \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), + \htmlspecialchars($clean($parts[1] ?? ''), \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), + ]; + } + /** * Sticky selection counter + empty-guard messaging for bulk forms. * Optional $actionButtonsHtml repeats Select All controls so every tab has them at the bottom. */ public static function renderStickyBar(string $actionButtonsHtml = ''): string { - $countTpl = \defined('_AM_MODULEINSTALLER_SELECTED_COUNT') ? \_AM_MODULEINSTALLER_SELECTED_COUNT : '%d selected'; - $noneMsg = \defined('_AM_MODULEINSTALLER_ERR_NONE_SELECTED') ? \_AM_MODULEINSTALLER_ERR_NONE_SELECTED : 'Select at least one module.'; - $parts = \explode('%d', (string) $countTpl, 2); - $label = \htmlspecialchars($parts[0] ?? '', \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') - . '0' - . \htmlspecialchars($parts[1] ?? '', \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + $countTpl = Lang::text('_AM_MODULEINSTALLER_SELECTED_COUNT', '%d selected'); + $noneMsg = Lang::text('_AM_MODULEINSTALLER_ERR_NONE_SELECTED', 'Select at least one module.'); + // : the counter is a neutral numeral at a direction boundary, so in an + // RTL admin '%d selected' rendered as "selected 1". The element is built here, + // where it is a constant, rather than passed into the splitter. + [$before, $after] = self::selectedCountParts($countTpl); + $label = $before + . '0' + . $after; $actions = '' !== $actionButtonsHtml ? '' . $actionButtonsHtml . '' @@ -368,8 +464,8 @@ public static function renderStickyBar(string $actionButtonsHtml = ''): string return '
' . '
' . '' . $label . '' - . '
' . $actions . '
'; @@ -389,23 +485,24 @@ public static function renderYesNoRadios(string $dirname, int $value): string $nameAttr = 'modules[' . \htmlspecialchars($dirname, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ']'; $idYes = $safeId . '_1'; $idNo = $safeId . '_2'; - $onclick = "selectModule('" . \htmlspecialchars($dirname, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . "', this)"; - $yesLabel = \defined('_YES') ? \_YES : 'Yes'; - $noLabel = \defined('_NO') ? \_NO : 'No'; + $onclick = self::jsCall('selectModule', $dirname, 'this'); + // Core constants from language//global.php, so a different domain. + $yesLabel = Lang::core('_YES', 'Yes'); + $noLabel = Lang::core('_NO', 'No'); $yesChk = (1 === $value) ? ' checked' : ''; $noChk = (1 === $value) ? '' : ' checked'; // Single-line nowrap unit — first row cannot stretch Yes/No across the cell - return '' + return '' . '  ' . ''; } @@ -419,13 +516,11 @@ public static function handlePost(string $action, string $successTitle): array $service = new ModuleActionService(); $selected = $service->selectedDirnames(self::postedModules()); if ([] === $selected) { - $msg = \defined('_AM_MODULEINSTALLER_ERR_NONE_SELECTED') - ? \_AM_MODULEINSTALLER_ERR_NONE_SELECTED - : 'Select at least one module (Yes) before continuing.'; + $msg = Lang::text('_AM_MODULEINSTALLER_ERR_NONE_SELECTED', 'Select at least one module (Yes) before continuing.'); return [ 'pageHasForm' => false, - 'content' => "
" . \htmlspecialchars((string) $msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
', + 'content' => "
" . \htmlspecialchars($msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
', 'results' => [], ]; } @@ -447,17 +542,16 @@ public static function handlePost(string $action, string $successTitle): array public static function renderReport(array $results, string $title = ''): string { if ([] === $results) { - $msg = \defined('_AM_MODULEINSTALLER_NO_SELECTION_REPORT') - ? \_AM_MODULEINSTALLER_NO_SELECTION_REPORT - : 'No modules selected.'; + $msg = Lang::text('_AM_MODULEINSTALLER_NO_SELECTION_REPORT', 'No modules selected.'); - return "
" . \htmlspecialchars((string) $msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
'; + return "
" . \htmlspecialchars($msg, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
'; } $html = ''; if ('' !== $title) { $html .= "
" . \htmlspecialchars($title, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '
'; } + $catalog = new ModuleCatalog(); $html .= "
    "; foreach ($results as $result) { if (! $result instanceof ModuleActionResult) { @@ -469,16 +563,199 @@ public static function renderReport(array $results, string $title = ''): string default => 'error', }; $prefix = \strtoupper($result->status); - // Core modulesadmin returns HTML in $message; messageHtml() strips it to a - // safe formatting subset so a hostile module name/version can't inject markup. - $html .= '
  • [' . \htmlspecialchars($prefix, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '] ' - . \htmlspecialchars($result->dirname, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ': ' - . $result->messageHtml() . '
  • '; + + // loadInfo() executes the module's own xoops_version.php. By this point + // the install/uninstall has already run, so a manifest that throws must + // not replace a completed report with a blank page: degrade to dirname. + $info = null; + if (1 === \preg_match('/^[a-zA-Z0-9_-]+$/', $result->dirname)) { + try { + $info = $catalog->loadInfo($result->dirname); + } catch (\Throwable) { + $info = null; + } + } + + $logoUrl = self::moduleLogoUrl($result->dirname, $info); + $name = null !== $info && '' !== \trim($info['name']) + ? $info['name'] + : $result->dirname; + + $html .= '
  • '; + + // Logo first in DOM order so it is also first in the reading order, and + // alt="" because the name is already in the label beside it: the image + // is decorative, and repeating the name makes a screen reader say it twice. + if (null !== $logoUrl) { + $html .= ''; + } else { + $html .= ''; + } + + $html .= '
    ' + . '
    [' + . \htmlspecialchars($prefix, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') + . '] ' + . \htmlspecialchars($name, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') + . ' (' + . \htmlspecialchars($result->dirname, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') + . ')
    ' + // messageHtml() emits no attribute except its own class, so nothing + // here needs a URL or attribute policy. + . '
    ' . $result->messageHtml() . '
    ' + . '
  • '; } return $html . '
'; } + /** + * An inline-handler call whose string argument is safe in a JS context. + * + * Centralised because getting this wrong is silent, and this file got it wrong + * twice: htmlspecialchars() is an HTML escaper, and inside an event-handler + * attribute the browser HTML-DECODES the value before the JavaScript is + * compiled. An entity-escaped apostrophe therefore arrives at the JS parser as + * a bare quote and closes the string literal, so a folder named + * + * foo'); alert(1);// + * + * became a live call. Core filters nothing — XoopsLists::getDirListAsArray() + * skips only dot-entries and 'cvs' — and an apostrophe is a legal filename + * character on NTFS and ext4 alike, so the name reaches the renderer verbatim. + * + * BOTH layers are needed, and the returned value is ATTRIBUTE-READY: + * + * 1. json_encode() for the JavaScript layer. The HEX flags keep <, >, &, ' + * and " out of the string BODY, so nothing in the value can end the + * literal or open a tag. + * 2. htmlspecialchars() for the HTML layer, over the whole call. This is not + * redundant: the HEX flags do not touch json_encode's own delimiters, so + * the result still begins and ends with a literal double quote, which + * would close a double-quoted attribute and turn the remainder into stray + * attributes on the element. Escaping to " keeps the attribute + * intact, and the browser decodes it back to a plain quote before the + * JavaScript is compiled — which is exactly the decode step that makes + * HTML-escaping alone unsafe, used here in the direction that is correct. + * + * Any new inline handler in this class must go through here. + * + * @param string $function JS function name — a literal in this file, never input + * @param string $argument the untrusted value, encoded as a JS string literal + * @param string $extraArgs additional raw JS arguments, e.g. 'this' + * + * @return string ready to interpolate into a quoted HTML attribute as-is + */ + private static function jsCall(string $function, string $argument, string $extraArgs = ''): string + { + $encoded = \json_encode( + $argument, + \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT + ); + + // false only for invalid UTF-8. An inert handler beats a malformed one. + if (false === $encoded) { + return 'void 0'; + } + + $call = $function . '(' . $encoded . ('' === $extraArgs ? '' : ', ' . $extraArgs) . ')'; + + return \htmlspecialchars($call, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Build a same-origin URL for a module logo after proving it is a local file. + * + * Module manifests are executable third-party code, so their image value is + * not inserted into an HTML attribute directly. Result reports call this + * helper to keep the legacy logo while preventing traversal or URL injection. + * + * The dirname allowlist is deliberately NOT widened to accept dots. It is not a + * logo rule — it is this module's dirname contract, applied identically by + * ModuleCatalog::existsOnDisk() and ModuleSet::withDirnames(). A module whose + * folder contains a dot is rejected by the catalog before a row is ever built, + * so relaxing it here alone would suppress nothing that currently renders; it + * would only make the one security-relevant path disagree with the other two. + * Widening is a module-wide decision that needs core's agreement and its own + * tests, not a local edit to the helper that happens to be reviewed. + * + * @param array{ + * dirname:string, + * name:string, + * version:mixed, + * module_status:mixed, + * image:string, + * description:string + * }|null $info + */ + public static function moduleLogoUrl(string $dirname, ?array $info): ?string + { + $dirname = \trim($dirname); + if ( + null === $info + || 1 !== \preg_match('/^[a-zA-Z0-9_-]+$/', $dirname) + ) { + return null; + } + + // 1 === preg_match(), not a bare truth test: preg_match() returns int|false, + // and its false-on-error is falsy — so a bare call would let a control + // character through on the one occasion the guard actually mattered. Matches + // the form the dirname check above already uses. + $image = \str_replace('\\', '/', \trim($info['image'])); + if ( + '' === $image + || \str_starts_with($image, '/') + || 1 === \preg_match('/[\x00-\x1F\x7F]/', $image) + ) { + return null; + } + + $segments = \explode('/', $image); + foreach ($segments as $segment) { + if ('' === $segment || '.' === $segment || '..' === $segment) { + return null; + } + } + + $moduleRoot = \realpath(\XOOPS_ROOT_PATH . '/modules/' . $dirname); + if (false === $moduleRoot) { + return null; + } + + $logoPath = \realpath($moduleRoot . \DIRECTORY_SEPARATOR . \implode(\DIRECTORY_SEPARATOR, $segments)); + if (false === $logoPath || ! \is_file($logoPath)) { + return null; + } + + // Containment check, case-folded only where the filesystem is: on a + // case-sensitive filesystem two genuinely different directories can differ + // only in case, and folding both sides would accept one for the other. + $rootPrefix = $moduleRoot . \DIRECTORY_SEPARATOR; + $inside = '\\' === \DIRECTORY_SEPARATOR + ? \str_starts_with(\mb_strtolower($logoPath), \mb_strtolower($rootPrefix)) + : \str_starts_with($logoPath, $rootPrefix); + if (! $inside) { + return null; + } + + // Containment proves the target is inside the module, not that it is an image. + // A manifest is third-party PHP and may name any contained file, and the value + // becomes an the browser will GET — pointing it at a .php in the same + // folder would execute that script server-side on every report render. An + // extension allowlist is the cheap half of that; the module's own files are + // web-reachable regardless, so this bounds what THIS page will ask for. + $extension = \mb_strtolower(\pathinfo($logoPath, \PATHINFO_EXTENSION)); + if (! \in_array($extension, ['png', 'gif', 'jpg', 'jpeg', 'webp', 'svg', 'ico'], true)) { + return null; + } + + $encodedImage = \implode('/', \array_map(\rawurlencode(...), $segments)); + + return \rtrim(\XOOPS_URL, '/') . '/modules/' . \rawurlencode($dirname) . '/' . $encodedImage; + } + /** * Select All / Un-Select All buttons plus Module Sets dropdown. * @@ -488,8 +765,9 @@ public static function displaySelectionControls(bool $showSetSelector = true): v { self::ensureAssets(); $adminObject = Admin::getInstance(); - $adminObject->addItemButton(_AM_MODULEINSTALLER_SELECT_ALL, 'javascript:selectAll();', 'button_ok'); - $adminObject->addItemButton(_AM_MODULEINSTALLER_SELECT_NONE, 'javascript:unselectAll();', 'prune'); + // Also raw constants until now, and reached from every listing page. + $adminObject->addItemButton(Lang::text('_AM_MODULEINSTALLER_SELECT_ALL', 'Select All'), 'javascript:selectAll();', 'button_ok'); + $adminObject->addItemButton(Lang::text('_AM_MODULEINSTALLER_SELECT_NONE', 'Un-Select All'), 'javascript:unselectAll();', 'prune'); $adminObject->displayButton('left', ''); if ($showSetSelector) { @@ -526,16 +804,10 @@ public static function renderSetSelector(): string } $jsonAttr = \htmlspecialchars($json, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); - $label = \defined('_AM_MODULEINSTALLER_APPLY_SET') ? \_AM_MODULEINSTALLER_APPLY_SET : 'Apply set'; - $placeholder = \defined('_AM_MODULEINSTALLER_APPLY_SET_PLACEHOLDER') - ? \_AM_MODULEINSTALLER_APPLY_SET_PLACEHOLDER - : '— Select a set —'; - $emptyHint = \defined('_AM_MODULEINSTALLER_APPLY_SET_EMPTY') - ? \_AM_MODULEINSTALLER_APPLY_SET_EMPTY - : 'No sets yet — create one under Module Sets.'; - $hint = \defined('_AM_MODULEINSTALLER_APPLY_SET_HINT') - ? \_AM_MODULEINSTALLER_APPLY_SET_HINT - : 'Checks Yes for set members listed on this page.'; + $label = Lang::text('_AM_MODULEINSTALLER_APPLY_SET', 'Apply set'); + $placeholder = Lang::text('_AM_MODULEINSTALLER_APPLY_SET_PLACEHOLDER', '— Select a set —'); + $emptyHint = Lang::text('_AM_MODULEINSTALLER_APPLY_SET_EMPTY', 'No sets yet — create one under Module Sets.'); + $hint = Lang::text('_AM_MODULEINSTALLER_APPLY_SET_HINT', 'Checks Yes for set members listed on this page.'); $lastSetId = isset($_COOKIE['installer_last_set']) ? (string) \preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $_COOKIE['installer_last_set']) @@ -543,15 +815,15 @@ public static function renderSetSelector(): string $html = '
'; $html .= ' '; + . \htmlspecialchars($hint, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '">' + . \htmlspecialchars($label, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ' '; $html .= ''; $html .= ' ' - . \htmlspecialchars((string) $hint, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ''; + . \htmlspecialchars($hint, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ''; $html .= '
'; // Self-contained handler: works even if xo-installer.js is missing or cached stale. diff --git a/class/Lang.php b/class/Lang.php new file mode 100644 index 0000000..fdae68e --- /dev/null +++ b/class/Lang.php @@ -0,0 +1,225 @@ +/admin.php (_AM_MODULEINSTALLER_*) + * core() → core's language//global.php (_YES, _NO, …) + * + * Each domain is loaded lazily, at most once per request, on the first lookup that + * misses. Loading goes through Xmf\Language when available — it resolves + * $xoopsConfig['language'], falls back to english, and refuses paths outside + * XOOPS_ROOT_PATH — and through xoops_loadLanguage() otherwise. Both APIs are + * long-standing, so this works on XOOPS 2.7.x and 2.8+ alike; the only real floor is + * the module's documented PHP 8.2. + * + * NOTE: this returns TEXT, never markup. Escaping stays at the call site, because the + * caller is the only one that knows whether the string is going into an element, an + * attribute or a JS literal. + */ +final class Lang +{ + /** This module's dirname, for the language domain. */ + private const DIRNAME = 'moduleinstaller'; + + /** Lazy load is attempted at most once per domain per request. */ + private static bool $moduleAttempted = false; + private static bool $globalAttempted = false; + + /** + * Text for one of this module's own constants. + * + * @param string $constant e.g. _AM_MODULEINSTALLER_SUBMIT + * @param string $fallback English text used when the constant is not defined + */ + public static function text(string $constant, string $fallback): string + { + if (! \defined($constant)) { + self::loadModule(); + } + + return self::usable($constant) ?? $fallback; + } + + /** + * The constant's value, or null when it cannot serve as a translation. + * + * "Defined" is not the same as "translated". Three states all mean fall back: + * + * - not defined at all — the usual partial-pack case; + * - defined as '' or as whitespace, which a half-finished pack produces in bulk + * and which renders as an invisible button or an empty table header. A blank + * label is not a deliberate translation choice often enough to be worth + * honouring, and English text in an Arabic admin is recoverable where a + * missing control is not. "Whitespace" here includes the Unicode blanks — a + * lone NBSP is what a translation tool emits for "intentionally empty", and it + * renders exactly as invisibly as a space does; + * - defined as something that is not SCALAR. Casting an array to string in PHP 8 + * emits a warning and yields the literal "Array"; a language file is ordinary + * PHP, so this is reachable by a typo rather than by malice. + * + * The rule is therefore "scalar, and non-blank after trim()" — not "must be a + * string". Two consequences are deliberate and easy to get backwards: + * + * - an int, a float or `true` IS used. A count or a version number legitimately + * arrives as a non-string scalar, and rejecting those would send a pack back to + * English over a missing pair of quotes; + * - `false` is NOT used, even though it is scalar, because it casts to '' — and + * '0' IS used, because it is a real translation of a real string and only looks + * empty. The blankness test is on the cast value, which gets both of these + * right for the same reason. + */ + private static function usable(string $constant): ?string + { + if (! \defined($constant)) { + return null; + } + + $value = \constant($constant); + if (! \is_scalar($value)) { + return null; + } + + $value = (string) $value; + + if ('' === \trim($value)) { + return null; + } + + // trim() knows only ASCII whitespace, so the test above still passes a value + // built solely from Unicode blanks — an NBSP-only constant being the common + // one. Rejected with a pattern rather than by widening the trim() charlist: + // trim() matches BYTES, and \xA0 is an ordinary UTF-8 continuation byte, so + // trimming "\xC2\xA0" truncates any value ending in a character that happens + // to end in \xA0 — "†" (\xE2\x80\xA0) becomes invalid UTF-8. That would put a + // mojibake bug in the one class whose job is surviving bad language packs. + // preg_match() returns false (not 1) on invalid UTF-8, which leaves such a + // value usable — the same answer the ASCII test above already gave it. + if (1 === \preg_match('/^[\s\p{Z}\x{FEFF}]+$/u', $value)) { + return null; + } + + return $value; + } + + /** + * Same as {@see self::text()} for a message with sprintf() placeholders. + * + * Formatting belongs here so a translation may reorder its arguments with + * %1$s/%2$s without the caller knowing or caring. + */ + public static function format(string $constant, string $fallback, int|float|string ...$args): string + { + $template = self::text($constant, $fallback); + + // sprintf() throws on PHP 8 when a template asks for more arguments than it + // is given, or uses an unknown conversion — and the template here comes from + // a translator, not from this codebase. Letting that propagate would take the + // admin page down over a typo in a language pack, which is the exact failure + // this class exists to prevent: it would make a bad translation worse than a + // missing one. Fall back to the English text, which is known to match $args. + try { + return \sprintf($template, ...$args); + } catch (\Throwable) { + try { + return \sprintf($fallback, ...$args); + } catch (\Throwable) { + // Even the fallback disagrees with the caller: return it unformatted + // rather than nothing, so the admin still sees a readable message. + return $fallback; + } + } + } + + /** + * Text for a CORE constant from the global language file (_YES, _NO, …). + * + * Separate from {@see self::text()} because a missing _YES is not fixed by + * loading this module's admin.php — it is a different file in a different domain. + */ + public static function core(string $constant, string $fallback): string + { + if (! \defined($constant)) { + self::loadGlobal(); + } + + // Same usability policy as text(). The two domains differ in WHERE the string + // comes from, not in what counts as a string: _YES from a half-translated core + // pack fails exactly the way _AM_MODULEINSTALLER_SUBMIT does, and a policy that + // applied to one domain and not the other would be a policy nobody could state. + return self::usable($constant) ?? $fallback; + } + + private static function loadModule(): void + { + if (self::$moduleAttempted) { + return; + } + self::$moduleAttempted = true; + self::load('admin', self::DIRNAME); + } + + private static function loadGlobal(): void + { + if (self::$globalAttempted) { + return; + } + self::$globalAttempted = true; + self::load('global', ''); + } + + /** + * Prefer Xmf\Language::load(): it picks the configured language, falls back to + * english on its own, and validates the path. xoops_loadLanguage() is the + * fallback for a core build without Xmf. + * + * Both routes `include` third-party PHP. A language pack is ordinary PHP, so it + * can throw — or be a parse error, which is a catchable `ParseError` on PHP 8. + * Uncaught, that would propagate out of text()/core() and take the admin page + * down, which is precisely the failure this class exists to prevent: a pack bad + * enough to throw is exactly the pack whose English fallback matters most. The + * attempt flag is set by the callers BEFORE this runs, so swallowing here cannot + * produce a retry loop — the next lookup goes straight to the fallback. + */ + private static function load(string $name, string $domain): void + { + try { + if (\class_exists(\Xmf\Language::class)) { + \Xmf\Language::load($name, $domain); + + return; + } + + if (\function_exists('xoops_loadLanguage')) { + \xoops_loadLanguage($name, $domain); + } + } catch (\Throwable) { + // Deliberately swallowed: usable() reports the constant as unavailable and + // every caller already carries the English text it needs. + } + } +} diff --git a/class/ModuleActionResult.php b/class/ModuleActionResult.php index f7e9f08..4197bc2 100644 --- a/class/ModuleActionResult.php +++ b/class/ModuleActionResult.php @@ -14,8 +14,54 @@ * @author Michael Beck (mamba) */ +use XoopsModules\Moduleinstaller\Report\LegacyModuleReportAdapter; +use XoopsModules\Moduleinstaller\Report\LogEventHtmlRenderer; + /** - * Result of a single module action (install/activate/etc.). + * Result of a single module action (install/uninstall/activate/deactivate/update). + * + * The message is core's install log: intentionally formatted HTML that also + * interpolates third-party strings without escaping them (modulesadmin.php + * interpolates $module->getVar('name'), $tpl['file'], $block['name'] and + * $db->error() raw), so it is untrusted markup that still carries meaning. + * + * Rendering policy: the log is treated as a TRANSCRIPT, not a document — it is + * parsed into {@see Report\LogEvent} values and rendered from string constants, so + * no value from the message can become a tag, an attribute name or an attribute + * value. See {@see Report\LegacyModuleReportAdapter} for the parse and + * {@see Report\LogEventHtmlRenderer} for the render; the properties that follow + * hold by construction rather than by policy: + * + * - the output can contain no attribute except the fixed classes the renderer + * writes, so there is no URL policy, no scheme denylist, no attribute allowlist; + * - escaping happens exactly once, in the renderer, so entities core already + * escaped round-trip instead of showing up as literal "&nbsp;"; + * - no optional extension is involved, so there is no fallback branch that can + * fail open, and no walk over a mutable tree that can fail to terminate. + * + * The module logo is resolved from validated module metadata by + * {@see AdminBulkPage::moduleLogoUrl()}, never from this message, so dropping the + * log's own loses nothing. + * + * LEGACY COMPATIBILITY TYPE. Being replaced by {@see Report\ModuleOperationResult}, + * which carries the transcript as data instead of as a string of HTML. The migration + * path exists in code, not only in prose: {@see ModuleActionService::operationResult()} + * and operationResults() return the new type, and {@see self::toOperationResult()} + * converts a result you already hold — so no new code has to name this class. + * + * No removal version is named, and that is deliberate. This shape is what every + * third-party caller of ModuleActionService uses, so it is supported for the whole + * XOOPS 2.8 line; removal becomes a question at XOOPS 4.0, where core emits the + * structured type itself and this class and the adapter disappear together. New code + * should take the Report types. + * + * Intentionally NOT tagged `@deprecated` yet. runOne()/runMany() — the primary public + * API of ModuleActionService — still return this type, and AdminBulkPage still renders + * from it, so the formal tag would mark as deprecated a type whose replacement no + * caller in this module has adopted. That is not a documentation nicety: it makes + * every internal use a deprecation violation (116 of them, which is what turned the + * QA gate red), training readers to ignore the tag on the release where it finally + * means something. The tag goes on when the internal consumers have moved. */ final readonly class ModuleActionResult { @@ -26,7 +72,7 @@ /** * @param string $dirname Module dirname * @param string $status One of STATUS_* - * @param string $message Human-readable message (may contain HTML from core) + * @param string $message Human-readable message (untrusted HTML from core) * @param string $action Action attempted (install, activate, …) */ public function __construct( @@ -55,20 +101,88 @@ public function isFail(): bool /** * Message rendered safe for admin output. * - * Core modulesadmin returns HTML install logs, but a module's own (possibly - * third-party) name/version is reflected inside them, so the message is fully - * untrusted. A tag allowlist is not enough — strip_tags() keeps attributes on - * permitted tags, so a name like "

" would still execute. Instead: - * turn block/line breaks into newlines, strip EVERY tag, HTML-escape the remaining - * text, then re-introduce only
for the preserved breaks. The sole markup in - * the output is that generated
, so no injected attribute can survive. + * Parse then render, as two steps that can be used separately. Both objects are + * stateless and a report renders a handful of rows, so they are built per call: + * `static $x = new Foo()` in a method needs PHP 8.3, and a readonly class cannot + * hold a `static ?Foo $x = null` cache at all, and this module supports PHP 8.2. */ public function messageHtml(): string { - $text = \preg_replace('#<(?:br\s*/?|/p|/div|/li|/tr)>#i', "\n", $this->message) ?? $this->message; - $text = \strip_tags($text); - $escaped = \htmlspecialchars($text, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + return (new LogEventHtmlRenderer())->render( + (new LegacyModuleReportAdapter())->parse($this->message) + ); + } + + /** + * The transcript as structured data — the shape everything should move to. + * + * @return list + */ + public function events(): array + { + return (new LegacyModuleReportAdapter())->parse($this->message); + } + + /** The transcript as plain text, for a CLI run, a log file or a mail. */ + public function messageText(): string + { + return \implode( + "\n", + \array_map(static fn (Report\LogEvent $e): string => $e->plainText(), $this->events()) + ); + } + + /** + * This result, projected onto the shape that replaces it. + * + * The projection lives HERE, on the legacy type, and not in the Report package + * — deliberately. A strangler fig only works if the dependency points from the old + * code to the new: Report\* must never mention ModuleActionResult, or deleting this + * class would mean editing the package that outlives it. When core emits events + * directly, this method is deleted along with its class and nothing in Report has + * to change. + * + * It is a pure function of $this, which is also what makes it testable without a + * running XOOPS. That matters: the duplicated-transcript bug this method now fixes + * survived a green suite precisely because the composition had only ever existed + * inside ModuleActionService, where no unit test could reach it. + * + * Two properties of core's string decide the mapping: + * + * - a SKIP's message is already plain prose ('Already installed'), never core + * HTML, because nothing ran. It becomes the reason and nothing else; + * - a FAILURE's message is ': ' — verdict and + * transcript concatenated, with no separator this class can rely on. So the + * first line becomes the reason and the transcript is kept AS IS. + * ModuleOperationResult::failed() would prepend the reason as a fresh event, + * which for this input means prepending a copy of the transcript to itself; + * failedWithTranscript() is the constructor for text that already states its + * own reason. + * + * That coupling is a property of core's string, not of this design, and it is + * exactly what disappears when core stops concatenating. + */ + public function toOperationResult(): Report\ModuleOperationResult + { + $events = $this->events(); + + if ($this->isOk()) { + return Report\ModuleOperationResult::ok($this->dirname, $this->action, $events); + } + + if ($this->isSkip()) { + // A skip's message is prose, not a log, so the whole of it is the reason — + // and skipped() builds its own single event from it. + return Report\ModuleOperationResult::skipped( + $this->dirname, + $this->action, + [] === $events ? \trim($this->message) : $events[0]->plainText() + ); + } - return \nl2br($escaped, false); + // No reason argument: failedWithTranscript() derives it from the first event, + // which is the one place the verdict can be. Passing it separately would be + // handing the same fact in twice and inviting the two copies to drift. + return Report\ModuleOperationResult::failedWithTranscript($this->dirname, $this->action, $events); } } diff --git a/class/ModuleActionService.php b/class/ModuleActionService.php index b8c4f9d..47ff88a 100644 --- a/class/ModuleActionService.php +++ b/class/ModuleActionService.php @@ -107,14 +107,19 @@ public function runOne(string $action, string $dirname): ModuleActionResult $action = \mb_strtolower(\trim($action)); if ('' === $dirname) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Empty dirname', $action); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_EMPTY_DIRNAME', 'Empty dirname'), $action); } if ($this->catalog->isProtected($dirname) && \in_array($action, [self::ACTION_UNINSTALL, self::ACTION_DEACTIVATE], true)) { return new ModuleActionResult( $dirname, ModuleActionResult::STATUS_SKIP, - \sprintf('Protected module "%s" cannot be %sd', $dirname, $action), + Lang::format( + '_AM_MODULEINSTALLER_RES_PROTECTED', + 'Protected module "%1$s": action "%2$s" is not allowed.', + $dirname, + $action + ), $action ); } @@ -128,18 +133,64 @@ public function runOne(string $action, string $dirname): ModuleActionResult self::ACTION_ACTIVATE => $this->doActivate($dirname), self::ACTION_DEACTIVATE => $this->doDeactivate($dirname), self::ACTION_UPDATE => $this->doUpdate($dirname), - default => new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Unknown action: ' . $action, $action), + default => new ModuleActionResult( + $dirname, + ModuleActionResult::STATUS_FAIL, + Lang::format('_AM_MODULEINSTALLER_RES_UNKNOWN_ACTION', 'Unknown action: %s', $action), + $action + ), }; } catch (\Throwable $e) { return new ModuleActionResult( $dirname, ModuleActionResult::STATUS_FAIL, - 'Exception: ' . $e->getMessage(), + Lang::format('_AM_MODULEINSTALLER_RES_EXCEPTION', 'Exception: %s', $e->getMessage()), $action ); } } + /** + * The same run, as a structured result. + * + * ADDITIVE, deliberately. runOne() keeps its signature and its return type for the + * whole XOOPS 2.8 line, because every third-party caller in existence calls it and + * a service method is a published contract even when nobody published it. This is + * the door out: new code takes {@see Report\ModuleOperationResult}, old code keeps + * working unchanged, and neither has to know about the other. + * + * Until core returns structured data, this is a projection of the legacy shape + * rather than a second implementation — there is exactly one place that decides + * what an action did (runOne), and it stays that way. When core 4.0 emits events + * directly, the arrow reverses: this becomes the real method and messageHtml() + * becomes the projection. + * + * The conversion itself is NOT here. It is a pure function of one result, so it + * lives on {@see ModuleActionResult::toOperationResult()} where a unit test can + * reach it without a running XOOPS. It was written here first, and that is exactly + * why its duplicated-transcript bug survived a green suite: no test could construct + * a service, so nothing exercised the composition of two individually-tested + * classes. A projection that only exists inside a service is a projection nobody + * can test. + */ + public function operationResult(string $action, string $dirname): Report\ModuleOperationResult + { + return $this->runOne($action, $dirname)->toOperationResult(); + } + + /** + * @param list $dirnames + * + * @return list + */ + public function operationResults(string $action, array $dirnames): array + { + return \array_map( + static fn (ModuleActionResult $r): Report\ModuleOperationResult => $r->toOperationResult(), + $this->runMany($action, $dirnames) + ); + } + /** * Flush cpanel and active-modules caches after bulk ops. */ @@ -157,77 +208,77 @@ public function flushCaches(): void private function doInstall(string $dirname): ModuleActionResult { if (! $this->catalog->existsOnDisk($dirname)) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not found on disk', self::ACTION_INSTALL); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_ON_DISK', 'Not found on disk'), self::ACTION_INSTALL); } if ($this->catalog->isInstalled($dirname)) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Already installed', self::ACTION_INSTALL); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_ALREADY_INSTALLED', 'Already installed'), self::ACTION_INSTALL); } $msg = \xoops_module_install($dirname); - return $this->verifiedResult($dirname, self::ACTION_INSTALL, (string) $msg, true, null, 'Install did not complete'); + return $this->verifiedResult($dirname, self::ACTION_INSTALL, (string) $msg, true, null, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_INSTALL', 'Install did not complete')); } private function doUninstall(string $dirname): ModuleActionResult { if (! $this->catalog->isInstalled($dirname)) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed', self::ACTION_UNINSTALL); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED', 'Not installed'), self::ACTION_UNINSTALL); } $msg = \xoops_module_uninstall($dirname); - return $this->verifiedResult($dirname, self::ACTION_UNINSTALL, (string) $msg, false, null, 'Uninstall did not complete'); + return $this->verifiedResult($dirname, self::ACTION_UNINSTALL, (string) $msg, false, null, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_UNINSTALL', 'Uninstall did not complete')); } private function doActivate(string $dirname): ModuleActionResult { $module = $this->catalog->getInstalled($dirname); if (! $module instanceof \XoopsModule) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed (cannot activate)', self::ACTION_ACTIVATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_ACTIVATE', 'Not installed (cannot activate)'), self::ACTION_ACTIVATE); } if ($module->isActive()) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Already active', self::ACTION_ACTIVATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_ALREADY_ACTIVE', 'Already active'), self::ACTION_ACTIVATE); } $mid = (int) $module->getVar('mid'); if ($mid <= 0) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Invalid module id', self::ACTION_ACTIVATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, Lang::text('_AM_MODULEINSTALLER_RES_INVALID_MID', 'Invalid module id'), self::ACTION_ACTIVATE); } $msg = \xoops_module_activate($mid); - return $this->verifiedResult($dirname, self::ACTION_ACTIVATE, (string) $msg, true, true, 'Activation did not take effect'); + return $this->verifiedResult($dirname, self::ACTION_ACTIVATE, (string) $msg, true, true, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_ACTIVATE', 'Activation did not take effect')); } private function doDeactivate(string $dirname): ModuleActionResult { $module = $this->catalog->getInstalled($dirname); if (! $module instanceof \XoopsModule) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed (cannot deactivate)', self::ACTION_DEACTIVATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_DEACTIVATE', 'Not installed (cannot deactivate)'), self::ACTION_DEACTIVATE); } if (! $module->isActive()) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Already inactive', self::ACTION_DEACTIVATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_ALREADY_INACTIVE', 'Already inactive'), self::ACTION_DEACTIVATE); } $mid = (int) $module->getVar('mid'); if ($mid <= 0) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Invalid module id', self::ACTION_DEACTIVATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, Lang::text('_AM_MODULEINSTALLER_RES_INVALID_MID', 'Invalid module id'), self::ACTION_DEACTIVATE); } $msg = \xoops_module_deactivate($mid); // Core silently refuses to deactivate system/start-page modules; a clean // deactivation must leave the module installed AND inactive (not vanished). - return $this->verifiedResult($dirname, self::ACTION_DEACTIVATE, (string) $msg, true, false, 'Deactivation did not take effect (start-page or protected module?)'); + return $this->verifiedResult($dirname, self::ACTION_DEACTIVATE, (string) $msg, true, false, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_DEACTIVATE', 'Deactivation did not take effect (start-page or protected module?)')); } private function doUpdate(string $dirname): ModuleActionResult { if (! $this->catalog->isInstalled($dirname)) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not installed', self::ACTION_UPDATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_INSTALLED', 'Not installed'), self::ACTION_UPDATE); } if (! $this->catalog->existsOnDisk($dirname)) { - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, 'Not found on disk', self::ACTION_UPDATE); + return new ModuleActionResult($dirname, ModuleActionResult::STATUS_SKIP, Lang::text('_AM_MODULEINSTALLER_RES_NOT_ON_DISK', 'Not found on disk'), self::ACTION_UPDATE); } $msg = \xoops_module_update($dirname); @@ -237,7 +288,7 @@ private function doUpdate(string $dirname): ModuleActionResult // update callback leaves the module installed at the new version. That failure // is therefore not detectable from DB state — we can only confirm the module // did not vanish. - return $this->verifiedResult($dirname, self::ACTION_UPDATE, (string) $msg, true, null, 'Update did not complete'); + return $this->verifiedResult($dirname, self::ACTION_UPDATE, (string) $msg, true, null, Lang::text('_AM_MODULEINSTALLER_RES_FAIL_UPDATE', 'Update did not complete')); } /** @@ -254,7 +305,16 @@ private function verifiedResult(string $dirname, string $action, string $msg, bo // Post-action state could not be read (no DB / query failed). For a // destructive bulk tool an unverifiable outcome is reported as a failure so // a genuinely failed operation is never silently presented as success. - return new ModuleActionResult($dirname, ModuleActionResult::STATUS_FAIL, 'Result could not be verified (module state unreadable): ' . $msg, $action); + return new ModuleActionResult( + $dirname, + ModuleActionResult::STATUS_FAIL, + Lang::format( + '_AM_MODULEINSTALLER_RES_UNVERIFIED', + 'Result could not be verified (module state unreadable): %s', + $msg + ), + $action + ); } $reached = $state['installed'] === $wantInstalled diff --git a/class/Report/LegacyModuleReportAdapter.php b/class/Report/LegacyModuleReportAdapter.php new file mode 100644 index 0000000..4211fcc --- /dev/null +++ b/class/Report/LegacyModuleReportAdapter.php @@ -0,0 +1,278 @@ + */ + public function parse(string $legacyHtml): array + { + if ('' === $legacyHtml) { + return []; + } + + return $this->toEvents($this->toMarkedText($legacyHtml)); + } + + /** + * Build a full result from core's return value plus the verdict the caller + * established by re-reading module state (which core's string cannot be trusted + * for — see ModuleActionService::verifiedResult()). + */ + public function result(string $dirname, string $action, Outcome $outcome, string $legacyHtml, ?string $reason = null): ModuleOperationResult + { + $events = $this->parse($legacyHtml); + + return match ($outcome) { + Outcome::Ok => ModuleOperationResult::ok($dirname, $action, $events), + Outcome::Skipped => ModuleOperationResult::skipped($dirname, $action, $reason ?? ''), + Outcome::Failed => ModuleOperationResult::failed($dirname, $action, $reason ?? '', $events), + }; + } + + /** + * Steps 1–5 of the current messageHtml(), stopping one step short of escaping: + * the result is PLAIN TEXT with emphasis/severity markers still embedded. + */ + private function toMarkedText(string $text): string + { + $m = self::M; + + // 1. control bytes out — also makes the markers unforgeable + $text = (string) \preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text); + + // 1b. normalise line endings. The step above deliberately keeps \r (it is + // not a control byte we want to silently delete), and lines are split on + // \n later, so CRLF input would otherwise leave a trailing \r inside a + // LogFragment — invisible once escaped, but wrong in a value object whose + // contract is clean text. + $text = \str_replace(["\r\n", "\r"], "\n", $text); + + // 2. elements whose content is not text + $text = (string) \preg_replace( + '#<(script|style|template|noscript|textarea|title|xmp)\b[^>]*>.*?#is', + '', + $text + ); + + // 3. the two constructs that carry meaning become markers + $text = (string) \preg_replace( + '##i', + $m . 'E', + $text + ); + // Every OTHER span gets an open marker too. It carries no severity, but it + // does carry a , and without a matching open that close would be + // read as ending the red span enclosing it — silently stopping the error + // run and repainting the rest of the transcript as ordinary output. Core + // does nest spans, so this is the common case, not a hostile one. Runs + // AFTER the red-span pass, which has already consumed those tags. + $text = (string) \preg_replace('#]*)?>#i', $m . 'O', $text); + $text = (string) \preg_replace('##i', $m . 'e', $text); + $text = (string) \preg_replace('#<(?:strong|b|em|i)(?:\s[^>]*)?>#i', $m . 'S', $text); + $text = (string) \preg_replace('##i', $m . 's', $text); + + // 4. line structure + $text = (string) \preg_replace('#<(?:br\s*/?|/p|/div|/li|/tr|/h[1-6])\s*>#i', "\n", $text); + + // 5. every other tag out, core's escaping collapsed. NO htmlspecialchars(): + // this is text now, and text is what the value object should hold. + return \html_entity_decode(\strip_tags($text), \ENT_QUOTES | \ENT_HTML5 | \ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Marked text to events. + * + * Emphasis and severity depths are intentionally declared OUTSIDE the line loop, + * so an emphasis or error span that core opens before a
and closes after it + * still applies to both lines. That reproduces what the string renderer did (one + * spanning the break) while producing per-line balanced markup instead, + * and core does emit spans that wrap a
. The cost is that an UNCLOSED span + * colours every line after it; a stray close pops nothing rather than going + * negative, so it cannot invert the state. + * + * Spans are tracked as a STACK, not a counter, because only the red ones carry + * severity while all of them share one closing tag. A counter had to guess which + * span a belonged to, and guessed the innermost open one — so an + * ordinary span nested inside a red one closed the red one, ending the error run + * early. The stack records what each open span was, so each close ends its own. + * + * @return list + */ + private function toEvents(string $marked): array + { + $events = []; + $emphasisDepth = 0; + /** @var list $spanStack true for a red (error) span, false for an ordinary one */ + $spanStack = []; + + foreach (\explode("\n", $marked) as $line) { + $fragments = []; + $buffer = ''; + // Seeded from the state carried in, so a line that only CLOSES a span core + // opened before the
still takes the severity. Reading the stack after + // the loop instead would miss it: the closing marker has already popped + // that span by then. + $lineHadError = \in_array(true, $spanStack, true); + + $parts = \preg_split( + '/(' . self::M . '[SsEeO])/', + $line, + -1, + \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY + ); + + foreach ((array) $parts as $part) { + switch ($part) { + case self::M . 'S': + $this->flushBuffer($buffer, $fragments, $emphasisDepth); + ++$emphasisDepth; + + break; + case self::M . 's': + $this->flushBuffer($buffer, $fragments, $emphasisDepth); + $emphasisDepth = \max(0, $emphasisDepth - 1); + + break; + case self::M . 'E': + $spanStack[] = true; + $lineHadError = true; + + break; + case self::M . 'O': + // Ordinary span: no severity, but it owns the next close. + $spanStack[] = false; + + break; + case self::M . 'e': + // Ends the innermost span, whatever kind it was. A close with + // nothing open pops nothing, which is how a stray from + // core's unbalanced fragments stays harmless. + \array_pop($spanStack); + + break; + default: + $buffer .= $part; + } + } + $this->flushBuffer($buffer, $fragments, $emphasisDepth); + + $severity = ($lineHadError || \in_array(true, $spanStack, true)) + ? LogSeverity::Error + : LogSeverity::Info; + $event = $this->withIndentExtracted($severity, $fragments); + + // Blank lines are an artifact of core's unbalanced fragments, + // not information. Dropping them here is why the renderer needs no + // "collapse runs of
" pass. + if (! $event->isBlank()) { + $events[] = $event; + } + } + + return $events; + } + + /** + * Move the pending text into a fragment, carrying the emphasis state it was + * accumulated under, and reset the buffer. + * + * A method rather than the by-reference closure this used to be. The closure was + * rebuilt on every line of every transcript, and — because static analysis reads a + * closure body against the state at its DECLARATION, where the buffer has just + * been set to '' — the `'' !== $buffer` guard was reported as always false. The + * guard is real at runtime; it was the shape that could not be verified. + * + * @param list $fragments + */ + private function flushBuffer(string &$buffer, array &$fragments, int $emphasisDepth): void + { + if ('' === $buffer) { + return; + } + + $fragments[] = new LogFragment($buffer, $emphasisDepth > 0); + $buffer = ''; + } + + /** + * Core indents nested steps with '  ' per level. Pull that out of the + * text into a depth, so the renderer can indent with CSS (or with anything else) + * instead of the transcript carrying presentation inside its own text. + * + * Indent is measured in NBSP ONLY. Two rules follow from that, both deliberate: + * + * - ASCII spaces and tabs are CONTENT, not structure. Core does not use them to + * nest, and a message that begins with one leading space means it, so leading + * whitespace stays in the text where the renderer will escape it. + * - an odd NBSP is not lost: only whole pairs become depth, and the remainder is + * put back at the head of the text. + * + * The match is on CHARACTERS, with /u. Doing this on bytes is a real bug and this + * method had it: ltrim($text, " \t\u{00A0}") builds a byte set {0x20, 0x09, 0xC2, + * 0xA0}, so a line starting with «, », ·, ¡, ¿, °, ± — anything in U+0080–U+00BF, + * all of which begin with the byte 0xC2 — lost its lead byte, leaving an orphan + * continuation byte that htmlspecialchars(ENT_SUBSTITUTE) turned into U+FFFD and + * that made json_encode() of the plain text return false. Translated packs use + * exactly those characters as bullets, so making the report translatable is what + * would have exposed it. + * + * @param list $fragments + */ + private function withIndentExtracted(LogSeverity $severity, array $fragments): LogEvent + { + if ([] === $fragments) { + return new LogEvent($severity, [], 0); + } + + $first = $fragments[0]; + if (1 !== \preg_match('/^(?:\x{00A0})+/u', $first->text, $lead)) { + return new LogEvent($severity, $fragments, 0); + } + + $units = \mb_strlen($lead[0], 'UTF-8'); + $depth = \intdiv($units, 2); + $remainder = \str_repeat(self::NBSP, $units % 2); + $rest = $remainder . \substr($first->text, \strlen($lead[0])); + + $fragments[0] = new LogFragment($rest, $first->emphasised); + if ('' === $rest) { + \array_shift($fragments); + } + + return new LogEvent($severity, \array_values($fragments), $depth); + } +} diff --git a/class/Report/LogEvent.php b/class/Report/LogEvent.php new file mode 100644 index 0000000..65f1663 --- /dev/null +++ b/class/Report/LogEvent.php @@ -0,0 +1,51 @@ + $fragments */ + public function __construct( + public LogSeverity $severity, + public array $fragments, + public int $depth = 0, + ) { + } + + /** Convenience for the common "one unemphasised line" case. */ + public static function text(string $text, LogSeverity $severity = LogSeverity::Info, int $depth = 0): self + { + return new self($severity, [new LogFragment($text)], $depth); + } + + /** The line as plain text — for logs, CLI output, JSON, tests. */ + public function plainText(): string + { + return \implode('', \array_map(static fn (LogFragment $f): string => $f->text, $this->fragments)); + } + + public function isBlank(): bool + { + return '' === \trim($this->plainText()); + } +} diff --git a/class/Report/LogEventHtmlRenderer.php b/class/Report/LogEventHtmlRenderer.php new file mode 100644 index 0000000..5fe696d --- /dev/null +++ b/class/Report/LogEventHtmlRenderer.php @@ -0,0 +1,69 @@ + $events */ + public function render(array $events): string + { + $lines = []; + foreach ($events as $event) { + $lines[] = $this->renderEvent($event); + } + + return \implode("
\n", $lines); + } + + private function renderEvent(LogEvent $event): string + { + $inner = ''; + foreach ($event->fragments as $fragment) { + $text = $this->escape($fragment->text); + $inner .= $fragment->emphasised ? '' . $text . '' : $text; + } + + $class = $event->severity->cssClass(); + if ('' !== $class) { + $inner = '' . $inner . ''; + } + + // Indent stays outside the severity span, exactly where core put it, and is + // emitted as NBSP so it survives HTML whitespace collapsing. A depth-based + // CSS indent (padding-inline-start) is now possible instead — the value + // object carries the depth, so that is a renderer decision, not a data one. + return $this->escape(\str_repeat(self::NBSP . self::NBSP, \max(0, $event->depth))) . $inner; + } + + private function escape(string $text): string + { + return \htmlspecialchars($text, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/class/Report/LogFragment.php b/class/Report/LogFragment.php new file mode 100644 index 0000000..28dd36f --- /dev/null +++ b/class/Report/LogFragment.php @@ -0,0 +1,32 @@ + around a table name, a template filename or a module id. + */ +final readonly class LogFragment +{ + public function __construct( + public string $text, + public bool $emphasised = false, + ) { + } +} diff --git a/class/Report/LogSeverity.php b/class/Report/LogSeverity.php new file mode 100644 index 0000000..7081151 --- /dev/null +++ b/class/Report/LogSeverity.php @@ -0,0 +1,41 @@ + '', + self::Success => 'installer-log-success', + self::Warning => 'installer-log-warning', + self::Error => 'installer-log-error', + }; + } +} diff --git a/class/Report/ModuleOperationResult.php b/class/Report/ModuleOperationResult.php new file mode 100644 index 0000000..1ed45e6 --- /dev/null +++ b/class/Report/ModuleOperationResult.php @@ -0,0 +1,202 @@ + $events */ + private function __construct( + public string $dirname, + public string $action, + public Outcome $outcome, + public array $events, + public ?string $reason = null, + ) { + } + + /** @param list $events */ + public static function ok(string $dirname, string $action, array $events = []): self + { + return new self($dirname, $action, Outcome::Ok, $events); + } + + /** A skip is a decision, not a failure: it always has a stated reason. */ + public static function skipped(string $dirname, string $action, string $reason): self + { + return new self($dirname, $action, Outcome::Skipped, [LogEvent::text($reason, LogSeverity::Warning)], $reason); + } + + /** + * A failure whose reason is NOT yet part of the transcript. + * + * The reason is prepended as an error event, so a consumer that only walks the + * transcript still sees the verdict. Use this when reason and transcript come from + * different places — a verdict this module established, plus whatever core said + * while getting there. + * + * @param list $events + */ + public static function failed(string $dirname, string $action, string $reason, array $events = []): self + { + return new self( + $dirname, + $action, + Outcome::Failed, + [LogEvent::text($reason, LogSeverity::Error), ...$events], + $reason + ); + } + + /** + * A failure whose transcript ALREADY states its reason. + * + * Needed because the legacy string does not separate verdict from transcript: + * ModuleActionService composes a failure message as ": ", so a projection of that string has only one body of text to work with. + * Handing it to failed() prepended a copy of the whole transcript to itself, and + * the plain-text view printed every line twice — visible in a CLI run, a log file + * and any JSON consumer, though not in messageHtml(), which never looks at events. + * + * NOTE THE SIGNATURE: no $reason. It is DERIVED from the first event, not supplied + * beside it. An earlier version took both and documented that the events "must + * already contain $reason" — a docblock is not an invariant, and a constructor + * that accepts two independent sources for one fact will eventually be handed two + * different facts. Deriving it makes the divergence unrepresentable. + * + * The two constructors exist rather than a boolean flag because the caller always + * knows which situation it is in, and the call site should say so. + * + * @param list $events the transcript, whose first line states the reason + */ + public static function failedWithTranscript(string $dirname, string $action, array $events): self + { + return new self( + $dirname, + $action, + Outcome::Failed, + $events, + [] === $events ? '' : $events[0]->plainText() + ); + } + + public function isOk(): bool + { + return Outcome::Ok === $this->outcome; + } + + public function isSkipped(): bool + { + return Outcome::Skipped === $this->outcome; + } + + public function isFailed(): bool + { + return Outcome::Failed === $this->outcome; + } + + /** + * True when any transcript line reported an error, even on an Ok outcome. + * + * DELIBERATELY INDEPENDENT OF $outcome, and it is worth saying why, because a + * reviewer reasonably proposed collapsing the two: a failed result whose transcript + * carries no red line answers false here, which reads like a contradiction. + * + * It is not one. The two answer different questions: + * + * - $outcome is THIS MODULE'S verdict, established by re-reading module state + * after the action (see ModuleActionService::verifiedResult()); + * - hasErrorEvents() is what CORE'S OWN LOG said while getting there. + * + * They disagree in both directions, and both disagreements are real and worth + * seeing. Core routinely returns a log containing red lines for an operation that + * nonetheless completed — a template that could not be added while the module + * installed fine — and, more importantly, core's string is exactly the thing that + * cannot be trusted for the verdict, which is why the verdict is re-derived at all. + * Promoting the reason to an Error event to make the two agree would delete the + * only signal that answers "core said this went fine; did its own log agree?". + * + * A renderer should therefore colour by {@see self::severity()}, which combines + * them, and never by scanning events alone. + */ + public function hasErrorEvents(): bool + { + foreach ($this->events as $event) { + if (LogSeverity::Error === $event->severity) { + return true; + } + } + + return false; + } + + /** + * The one severity that describes this result, for a consumer that wants one. + * + * The verdict wins where it is decisive — a failure is an error however calm its + * transcript reads — and the transcript speaks where the verdict is not: an Ok + * result whose log contains red lines is a Warning, not a clean Success, and that + * is the case a transcript-only renderer would otherwise paint green. + */ + public function severity(): LogSeverity + { + return match (true) { + $this->isFailed() => LogSeverity::Error, + $this->isSkipped() => LogSeverity::Warning, + $this->hasErrorEvents() => LogSeverity::Warning, + default => LogSeverity::Success, + }; + } + + /** The whole transcript as plain text — CLI, log file, mail, test assertion. */ + public function plainText(): string + { + return \implode("\n", \array_map(static fn (LogEvent $e): string => $e->plainText(), $this->events)); + } + + /** @return array{dirname:string,action:string,outcome:string,severity:string,reason:?string,events:list} */ + public function toArray(): array + { + return [ + 'dirname' => $this->dirname, + 'action' => $this->action, + 'outcome' => $this->outcome->value, + // The combined verdict, so a JSON consumer does not have to re-implement + // the outcome/transcript precedence rule to colour one row. + 'severity' => $this->severity()->value, + 'reason' => $this->reason, + 'events' => \array_map( + static fn (LogEvent $e): array => [ + 'severity' => $e->severity->value, + 'depth' => $e->depth, + 'text' => $e->plainText(), + ], + $this->events + ), + ]; + } +} diff --git a/class/Report/Outcome.php b/class/Report/Outcome.php new file mode 100644 index 0000000..7813f7a --- /dev/null +++ b/class/Report/Outcome.php @@ -0,0 +1,39 @@ + 'success', + self::Skipped => 'warning', + self::Failed => 'error', + }; + } +} diff --git a/class/Set/ModuleSet.php b/class/Set/ModuleSet.php index 5c2bbbf..959fcec 100644 --- a/class/Set/ModuleSet.php +++ b/class/Set/ModuleSet.php @@ -20,7 +20,7 @@ final class ModuleSet { /** @var list */ - private array $modules = []; + private array $modules; /** * @param array $modules Raw dirnames; normalized (validated, de-duped, sorted) on construction. diff --git a/class/Set/ModuleSetApplier.php b/class/Set/ModuleSetApplier.php index 568ad62..b5ff0ff 100644 --- a/class/Set/ModuleSetApplier.php +++ b/class/Set/ModuleSetApplier.php @@ -14,6 +14,7 @@ * @author Michael Beck (mamba) */ +use XoopsModules\Moduleinstaller\Lang; use XoopsModules\Moduleinstaller\ModuleActionResult; use XoopsModules\Moduleinstaller\ModuleActionService; use XoopsModules\Moduleinstaller\ModuleCatalog; @@ -94,11 +95,20 @@ public function execute(ModuleSet $set, string $action, bool $snapshotBeforeFocu try { $snapshot = $this->createSnapshot($set); $snapshotId = $snapshot->getId(); - $notices[] = 'Saved snapshot: ' . $snapshot->getName() . ' (' . $snapshotId . ')'; + $notices[] = Lang::format( + '_AM_MODULEINSTALLER_SET_SNAPSHOT_NOTICE', + 'Saved snapshot: %1$s (%2$s)', + $snapshot->getName(), + $snapshotId + ); } catch (\Throwable $e) { // The snapshot is the ONLY way to undo a Focus. If it cannot be saved, // abort before deactivating anything so the operation stays recoverable. - $notices[] = 'Aborted: could not save recovery snapshot before Focus (' . $e->getMessage() . ')'; + $notices[] = Lang::format( + '_AM_MODULEINSTALLER_SET_SNAPSHOT_ABORTED', + 'Aborted: could not save recovery snapshot before Focus (%s)', + $e->getMessage() + ); return [ 'results' => [], @@ -124,7 +134,7 @@ public function execute(ModuleSet $set, string $action, bool $snapshotBeforeFocu $results[] = new ModuleActionResult( $dirname, ModuleActionResult::STATUS_SKIP, - (string) ($row['notice'] ?? 'Missing on disk'), + (string) ($row['notice'] ?? Lang::text('_AM_MODULEINSTALLER_BADGE_MISSING', 'Missing on disk')), $action ); } @@ -145,13 +155,23 @@ public function execute(ModuleSet $set, string $action, bool $snapshotBeforeFocu public function createSnapshot(ModuleSet $beforeFocusSet): ModuleSet { $active = $this->catalog->listInstalled(true); - $name = 'Snapshot before Focus: ' . $beforeFocusSet->getName(); + // Stored with the snapshot and displayed later, possibly under a different + // locale: localised at creation time on purpose, rather than storing a key. + $name = Lang::format( + '_AM_MODULEINSTALLER_SET_SNAPSHOT_NAME', + 'Snapshot before Focus: %s', + $beforeFocusSet->getName() + ); $id = $this->repository->uniqueId('snapshot-' . \gmdate('Ymd-His')); $set = new ModuleSet( $id, $name, - 'Auto-created before applying Focus on set "' . $beforeFocusSet->getId() . '"', + Lang::format( + '_AM_MODULEINSTALLER_SET_SNAPSHOT_DESC', + 'Auto-created before applying Focus on set "%s"', + $beforeFocusSet->getId() + ), $active, ); $this->repository->save($set); @@ -207,7 +227,7 @@ private function planMemberAction(ModuleSet $set, string $action): array $plan[] = [ 'dirname' => $dirname, 'action' => $action, - 'reason' => 'Member of set', + 'reason' => Lang::text('_AM_MODULEINSTALLER_SET_REASON_MEMBER', 'Member of set'), ]; } } @@ -235,7 +255,7 @@ private function planFocus(ModuleSet $set): array $plan[] = [ 'dirname' => $row['dirname'], 'action' => self::ACTION_ACTIVATE, - 'reason' => 'Focus: activate set member', + 'reason' => Lang::text('_AM_MODULEINSTALLER_SET_REASON_FOCUS_ON', 'Focus: activate set member'), ]; } } @@ -251,7 +271,7 @@ private function planFocus(ModuleSet $set): array $plan[] = [ 'dirname' => $dirname, 'action' => self::ACTION_DEACTIVATE, - 'reason' => 'Focus: deactivate non-member', + 'reason' => Lang::text('_AM_MODULEINSTALLER_SET_REASON_FOCUS_OFF', 'Focus: deactivate non-member'), ]; } diff --git a/class/Set/ModuleSetResolver.php b/class/Set/ModuleSetResolver.php index e67985a..60d8dd6 100644 --- a/class/Set/ModuleSetResolver.php +++ b/class/Set/ModuleSetResolver.php @@ -14,6 +14,7 @@ * @author Michael Beck (mamba) */ +use XoopsModules\Moduleinstaller\Lang; use XoopsModules\Moduleinstaller\ModuleCatalog; /** @@ -75,18 +76,18 @@ public function resolveOne(string $dirname): array // Installed in the DB but its folder is gone: a distinct state so bulk // planning never tries to activate/install a module with no files. $state = self::STATE_ORPHANED; - $notice = 'Installed in database but folder missing on disk'; + $notice = Lang::text('_AM_MODULEINSTALLER_RES_ORPHANED', 'Installed in database but folder missing on disk'); } elseif (! $onDisk && ! $installed) { $state = self::STATE_MISSING; - $notice = 'Not found on disk (removed module)'; + $notice = Lang::text('_AM_MODULEINSTALLER_RES_REMOVED', 'Not found on disk (removed module)'); } elseif ($protected) { $state = self::STATE_PROTECTED; - $notice = 'Protected module'; + $notice = Lang::text('_AM_MODULEINSTALLER_BADGE_PROTECTED', 'Protected module'); } elseif (! $installed) { // Reached only when on disk (the installed && !on_disk case is ORPHANED above, // and !on_disk && !installed is MISSING), so the folder is always present here. $state = self::STATE_NOT_INSTALLED; - $notice = 'Present on disk but not installed'; + $notice = Lang::text('_AM_MODULEINSTALLER_RES_ON_DISK_NOT_INSTALLED', 'Present on disk but not installed'); } elseif ($active) { $state = self::STATE_ACTIVE; $notice = null; diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 7b34857..ee23cf4 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -1,4 +1,4 @@ -# ModuleInstaller Tutorial (1.6.0) +# ModuleInstaller Tutorial (1.7.0 Alpha 1) ## What this module does @@ -160,5 +160,5 @@ After Focus, a success notice may include a link to open the **auto-snapshot** s ## See also - [README.md](../README.md) — overview, architecture, development -- [CHANGELOG.md](../CHANGELOG.md) / [changelog.txt](changelog.txt) — release history +- [CHANGELOG.md](../CHANGELOG.md) / [changelog.txt](changelog.txt) — release history · [lang_diff.txt](lang_diff.txt) — language changes - Tutorial (GitBook): https://xoops.gitbook.io/xoops-moduleinstaller/ diff --git a/docs/changelog.txt b/docs/changelog.txt index e497541..432af16 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -1,6 +1,56 @@ ModuleInstaller CHANGELOG ========================= -Compared to previous public baseline 1.5.1-Final (L:\GitHub\00mambax7\moduleinstaller / 2022-12-15). + +-------------------------------------------------------------------------------- +1.7.0 Alpha 1 [2026-07-30] +Dev: XOOPS 2.7.0+, PHP 8.2+, mtools 1.2.0+ +-------------------------------------------------------------------------------- + +Highlights +- Rebuilt install-action reporting as structured data that can be rendered safely + to HTML or consumed as plain text, CLI output, logs, or JSON. +- Hardened admin reports against unsafe module-supplied markup, paths, malformed + manifests, invalid UTF-8, and incomplete or malformed language packs. +- Improved translated and RTL admin output, accessibility, and responsive styling + without breaking existing ModuleActionService callers. + +Security / correctness +- The install-log renderer now parses untrusted core HTML into typed events, + escapes every value exactly once, emits only fixed module-owned markup, and + discards executable or raw-text element bodies. +- Module logo URLs now use one validated, platform-aware path resolver; unsafe, + remote, missing, and traversal paths are rejected. +- Manifest loading failures no longer replace a completed operation report with a + blank page. +- Fixed invalid UTF-8 caused by byte-based NBSP trimming and duplicate failure + transcripts in structured output. + +Structured reports and compatibility +- Added the class/Report/ package with typed log fragments, events, severities, + outcomes, operation results, a legacy adapter, and the HTML renderer. +- ModuleActionResult now exposes events(), messageText(), and + toOperationResult(); ModuleActionService adds operationResult() and + operationResults(). +- ModuleActionResult is deprecated in favor of Report\ModuleOperationResult, while + existing runOne()/runMany() signatures remain supported through XOOPS 2.8. +- Structured results expose a combined severity for consumers while preserving + the distinction between the operation outcome and errors reported by core. + +Internationalisation and admin UI +- Added 30 translatable admin-report strings and Lang helpers with safe English + fallbacks for missing, blank, non-scalar, or malformed translations. +- Added bidirectional isolation for counters, versions, statuses, folders, and + plan summaries in RTL admin interfaces. +- Improved long-report scrolling and screen-reader ordering; converted module CSS + to logical properties and removed module-owned inline style attributes. + +Tests +- Expanded the suite to 109 tests / 264 assertions covering hostile log input, + structured-result contracts, language fallbacks, logo-path rejection and UTF-8 + boundary handling. +- The module-logo POSITIVE path and the image-extension allowlist are not unit + covered: both need a real file under XOOPS_ROOT_PATH/modules//, so they + belong to the integration suite. Only the rejection contract is asserted here. -------------------------------------------------------------------------------- 1.6.0 Stable [2026-07-23] @@ -66,7 +116,7 @@ Quality / tooling (new vs 1.5.1) - phpstan.neon and phpstan analysis notes. - Expanded language strings (admin sets UI, apply-set hints). -UX polish (still 1.6.0) +UX polish (introduced in 1.6.0) - Apply Set form: clearer vertical spacing between intro, Action, Preview plan, Set member status, confirm checkbox, and Run/Cancel. - Module Set edit: selected members highlighted with light green (#E6EFC2), @@ -75,7 +125,7 @@ UX polish (still 1.6.0) selection (same as radio / checkbox). - docs/TUTORIAL.md end-user walkthrough. -Phase 1–2 UX (still 1.6.0, 2026-07-23) +Phase 1–2 UX (introduced in 1.6.0, 2026-07-23) - Bulk + set-edit filter box; keyboard / and A; sticky “N selected” bar; empty Yes selection blocked on submit. - Status badges; Apply plan summary counts; Focus/Uninstall confirm copy split; @@ -87,7 +137,7 @@ Phase 1–2 UX (still 1.6.0, 2026-07-23) - ModuleCatalog::candidatesFor / needsUpdate helpers. - docs/TODO.md updated; docs/MTOOLS-INVESTIGATION.md (Phase 3 prerequisite). -Phase 3 structure & polish (still 1.6.0, 2026-07-23) +Phase 3 structure & polish (introduced in 1.6.0, 2026-07-23) - Bulk tabs: AdminBulkPage::serve() only (no install_tpl / InstallWizard dual chrome). - Thin admin wrappers; CSRF on bulk POST; renderReport in presentation layer. - Removed extras/, wizard files (InstallWizard, common.inc, install_tpl, page.php, diff --git a/docs/lang_diff.txt b/docs/lang_diff.txt index f4cb3dc..c1653ef 100644 --- a/docs/lang_diff.txt +++ b/docs/lang_diff.txt @@ -8,3 +8,48 @@ Below are language differences from a version to next version. /language/english/admin.php added define('_AM_MODULEINSTALLER_SELECT_ALL' , "Select All"); added define('_AM_MODULEINSTALLER_SELECT_NONE' , "Un-Select All"); + +1.7.0 +------------------- +/language/english/admin.php +added define('_AM_MODULEINSTALLER_RES_EMPTY_DIRNAME' , 'Empty dirname'); +added define('_AM_MODULEINSTALLER_RES_PROTECTED' , 'Protected module "%1$s": action "%2$s" is not allowed.'); +added define('_AM_MODULEINSTALLER_RES_UNKNOWN_ACTION' , 'Unknown action: %s'); +added define('_AM_MODULEINSTALLER_RES_EXCEPTION' , 'Exception: %s'); +added define('_AM_MODULEINSTALLER_RES_NOT_ON_DISK' , 'Not found on disk'); +added define('_AM_MODULEINSTALLER_RES_ALREADY_INSTALLED' , 'Already installed'); +added define('_AM_MODULEINSTALLER_RES_NOT_INSTALLED' , 'Not installed'); +added define('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_ACTIVATE' , 'Not installed (cannot activate)'); +added define('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_DEACTIVATE' , 'Not installed (cannot deactivate)'); +added define('_AM_MODULEINSTALLER_RES_ALREADY_ACTIVE' , 'Already active'); +added define('_AM_MODULEINSTALLER_RES_ALREADY_INACTIVE' , 'Already inactive'); +added define('_AM_MODULEINSTALLER_RES_INVALID_MID' , 'Invalid module id'); +added define('_AM_MODULEINSTALLER_RES_UNVERIFIED' , 'Result could not be verified (module state unreadable): %s'); +added define('_AM_MODULEINSTALLER_RES_FAIL_INSTALL' , 'Install did not complete'); +added define('_AM_MODULEINSTALLER_RES_FAIL_UNINSTALL' , 'Uninstall did not complete'); +added define('_AM_MODULEINSTALLER_RES_FAIL_ACTIVATE' , 'Activation did not take effect'); +added define('_AM_MODULEINSTALLER_RES_FAIL_DEACTIVATE' , 'Deactivation did not take effect (start-page or protected module?)'); +added define('_AM_MODULEINSTALLER_RES_FAIL_UPDATE' , 'Update did not complete'); +added define('_AM_MODULEINSTALLER_RES_ORPHANED' , 'Installed in database but folder missing on disk'); +added define('_AM_MODULEINSTALLER_RES_REMOVED' , 'Not found on disk (removed module)'); +added define('_AM_MODULEINSTALLER_RES_ON_DISK_NOT_INSTALLED' , 'Present on disk but not installed'); +added define('_AM_MODULEINSTALLER_TOGGLE_SELECTION' , 'Toggle selection'); +added define('_AM_MODULEINSTALLER_FOLDER_LABEL' , 'folder: /%s'); +added define('_AM_MODULEINSTALLER_SET_SNAPSHOT_NOTICE' , 'Saved snapshot: %1$s (%2$s)'); +added define('_AM_MODULEINSTALLER_SET_SNAPSHOT_NAME' , 'Snapshot before Focus: %s'); +added define('_AM_MODULEINSTALLER_SET_REASON_MEMBER' , 'Member of set'); +added define('_AM_MODULEINSTALLER_SET_REASON_FOCUS_ON' , 'Focus: activate set member'); +added define('_AM_MODULEINSTALLER_SET_REASON_FOCUS_OFF' , 'Focus: deactivate non-member'); +added define('_AM_MODULEINSTALLER_SET_SNAPSHOT_ABORTED' , 'Aborted: could not save recovery snapshot before Focus (%s)'); +added define('_AM_MODULEINSTALLER_SET_SNAPSHOT_DESC' , 'Auto-created before applying Focus on set "%s"'); + +Notes +- The RES_* block makes the bulk result report translatable. Those strings were + English literals inside class/ModuleActionService.php until now, so the report + was untranslatable regardless of the language pack installed. +- Every lookup goes through class/Lang.php, which carries the English text as a + fallback: a language pack that omits one of these degrades to English instead of + causing a fatal error (an undefined constant is a fatal Error on PHP 8, not a + notice). +- Translators: %1$s / %2$s ordering may be reordered freely; Lang::format() passes + the arguments positionally. diff --git a/docs/readme.txt b/docs/readme.txt index 1ccdd73..3a9e567 100644 --- a/docs/readme.txt +++ b/docs/readme.txt @@ -6,10 +6,10 @@ and deactivate modules in bulk, and to manage Module Sets for focused testing. Version ------- -1.6.0 Stable (2026-07-23) +1.7.0 Alpha 1 (2026-07-30) Previous public baseline: 1.5.1 Final (2022-12-15) -See CHANGELOG.md and docs/changelog.txt for the full 1.5.1 → 1.6.0 delta. +See CHANGELOG.md and docs/changelog.txt for the full history, and docs/lang_diff.txt for language changes. Requirements diff --git a/language/english/admin.php b/language/english/admin.php index a68782c..cd55beb 100644 --- a/language/english/admin.php +++ b/language/english/admin.php @@ -118,3 +118,44 @@ define('_AM_MODULEINSTALLER_BADGE_NOT_INSTALLED', 'Not installed'); define('_AM_MODULEINSTALLER_BADGE_MISSING', 'Missing on disk'); define('_AM_MODULEINSTALLER_BADGE_PROTECTED', 'Protected'); + +// Bulk result report — one line per module in the report produced by +// ModuleActionService. These were English literals inside the service until now, +// which made the report untranslatable regardless of the language pack installed. +define('_AM_MODULEINSTALLER_RES_EMPTY_DIRNAME', 'Empty dirname'); +define('_AM_MODULEINSTALLER_RES_PROTECTED', 'Protected module "%1$s": action "%2$s" is not allowed.'); +define('_AM_MODULEINSTALLER_RES_UNKNOWN_ACTION', 'Unknown action: %s'); +define('_AM_MODULEINSTALLER_RES_EXCEPTION', 'Exception: %s'); +define('_AM_MODULEINSTALLER_RES_NOT_ON_DISK', 'Not found on disk'); +define('_AM_MODULEINSTALLER_RES_ALREADY_INSTALLED', 'Already installed'); +define('_AM_MODULEINSTALLER_RES_NOT_INSTALLED', 'Not installed'); +define('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_ACTIVATE', 'Not installed (cannot activate)'); +define('_AM_MODULEINSTALLER_RES_NOT_INSTALLED_DEACTIVATE', 'Not installed (cannot deactivate)'); +define('_AM_MODULEINSTALLER_RES_ALREADY_ACTIVE', 'Already active'); +define('_AM_MODULEINSTALLER_RES_ALREADY_INACTIVE', 'Already inactive'); +define('_AM_MODULEINSTALLER_RES_INVALID_MID', 'Invalid module id'); +define('_AM_MODULEINSTALLER_RES_UNVERIFIED', 'Result could not be verified (module state unreadable): %s'); +define('_AM_MODULEINSTALLER_RES_FAIL_INSTALL', 'Install did not complete'); +define('_AM_MODULEINSTALLER_RES_FAIL_UNINSTALL', 'Uninstall did not complete'); +define('_AM_MODULEINSTALLER_RES_FAIL_ACTIVATE', 'Activation did not take effect'); +define('_AM_MODULEINSTALLER_RES_FAIL_DEACTIVATE', 'Deactivation did not take effect (start-page or protected module?)'); +define('_AM_MODULEINSTALLER_RES_FAIL_UPDATE', 'Update did not complete'); + +// Set-resolver notices — same report, same reason they need constants. +define('_AM_MODULEINSTALLER_RES_ORPHANED', 'Installed in database but folder missing on disk'); +define('_AM_MODULEINSTALLER_RES_REMOVED', 'Not found on disk (removed module)'); +define('_AM_MODULEINSTALLER_RES_ON_DISK_NOT_INSTALLED', 'Present on disk but not installed'); + +// Module list chrome + module-set notices (round 4: the last English literals +// outside the language files). +define('_AM_MODULEINSTALLER_TOGGLE_SELECTION', 'Toggle selection'); +define('_AM_MODULEINSTALLER_FOLDER_LABEL', 'folder: /%s'); +define('_AM_MODULEINSTALLER_SET_SNAPSHOT_NOTICE', 'Saved snapshot: %1$s (%2$s)'); +define('_AM_MODULEINSTALLER_SET_SNAPSHOT_NAME', 'Snapshot before Focus: %s'); +define('_AM_MODULEINSTALLER_SET_REASON_MEMBER', 'Member of set'); +define('_AM_MODULEINSTALLER_SET_REASON_FOCUS_ON', 'Focus: activate set member'); +define('_AM_MODULEINSTALLER_SET_REASON_FOCUS_OFF', 'Focus: deactivate non-member'); + +// Set-apply recovery snapshot (round 5: the last hardcoded strings on this path). +define('_AM_MODULEINSTALLER_SET_SNAPSHOT_ABORTED', 'Aborted: could not save recovery snapshot before Focus (%s)'); +define('_AM_MODULEINSTALLER_SET_SNAPSHOT_DESC', 'Auto-created before applying Focus on set "%s"'); diff --git a/tests/Unit/class/AdminBulkPageJsEscapingTest.php b/tests/Unit/class/AdminBulkPageJsEscapingTest.php new file mode 100644 index 0000000..bc651f0 --- /dev/null +++ b/tests/Unit/class/AdminBulkPageJsEscapingTest.php @@ -0,0 +1,107 @@ +firstOnclick($html)); + } + + /** @return iterable */ + public static function breakoutAttempts(): iterable + { + yield 'apostrophe breakout' => ["foo'); alert(1);//"]; + yield 'double quote breakout' => ['foo"); alert(1);//']; + yield 'script tag' => ['foo']; + yield 'ampersand entity' => ['foo'); alert(1);//']; + yield 'double encoded' => ['foo&#039;); alert(1);//']; + yield 'closing attribute' => ['foo" onmouseover="alert(1)']; + yield 'backslash' => ['foo\\"); alert(1);//']; + yield 'newline' => ["foo\n); alert(1);//"]; + } + + /** + * The decisive assertion: after the browser decodes the attribute, the payload + * must still be INSIDE the JS string literal. If it escaped, the decoded text + * would contain a bare quote followed by the injected call. + */ + #[Test] + #[DataProvider('breakoutAttempts')] + public function hostileDirnameCannotEscapeTheJsStringLiteral(string $dirname): void + { + $decoded = $this->firstOnclick(AdminBulkPage::renderYesNoRadios($dirname, 0)); + + // Exactly one call, with exactly two arguments, and the payload contained. + self::assertMatchesRegularExpression( + '/^selectModule\("(?:[^"\\\\]|\\\\.)*", this\)$/', + $decoded, + 'handler is not a single well-formed call: ' . $decoded + ); + // No raw quote of either kind can appear inside the literal body. + $body = \substr($decoded, \strlen('selectModule("'), -\strlen('", this)')); + self::assertStringNotContainsString("'", $body); + self::assertStringNotContainsString('"', $body); + self::assertStringNotContainsString('<', $body); + } + + /** Both radios must carry the same handler — the bug was per-attribute, not per-row. */ + #[Test] + public function bothRadiosCarryTheIdenticalSafeHandler(): void + { + $html = AdminBulkPage::renderYesNoRadios("foo'); alert(1);//", 1); + + \preg_match_all('/onclick="([^"]*)"/', $html, $m); + self::assertCount(2, $m[1], 'expected a handler on each radio'); + self::assertSame($m[1][0], $m[1][1]); + } + + /** The element id is a separate, HTML-side concern and stays sanitised. */ + #[Test] + public function elementIdIsSanitisedIndependently(): void + { + $html = AdminBulkPage::renderYesNoRadios("foo'); alert(1);//", 1); + + self::assertStringContainsString('id="modsel_foo____alert_1_____1"', $html); + self::assertStringNotContainsString('id="modsel_foo\'', $html); + } + + /** Invalid UTF-8 yields an inert handler rather than a malformed one. */ + #[Test] + public function invalidUtf8DirnameYieldsAnInertHandler(): void + { + $decoded = $this->firstOnclick(AdminBulkPage::renderYesNoRadios("bad\xC3\x28name", 0)); + + self::assertSame('void 0', $decoded); + } + + /** The onclick attribute value as the JavaScript parser would receive it. */ + private function firstOnclick(string $html): string + { + if (1 !== \preg_match('/onclick="([^"]*)"/', $html, $m)) { + self::fail('no onclick attribute found in: ' . $html); + } + + return \html_entity_decode($m[1], \ENT_QUOTES | \ENT_HTML5, 'UTF-8'); + } +} diff --git a/tests/Unit/class/AdminBulkPageLogoUrlTest.php b/tests/Unit/class/AdminBulkPageLogoUrlTest.php new file mode 100644 index 0000000..6fbe00e --- /dev/null +++ b/tests/Unit/class/AdminBulkPageLogoUrlTest.php @@ -0,0 +1,108 @@ +. Every case below must resolve to null BEFORE any + * filesystem call, which is what makes them assertable without a booted XOOPS. + * + * NOT covered here, and deliberately: the positive path and the image-extension + * allowlist both need a real file under XOOPS_ROOT_PATH/modules//, which + * a unit run has no writable location for. Those need the integration suite. + */ +#[CoversClass(AdminBulkPage::class)] +final class AdminBulkPageLogoUrlTest extends TestCase +{ + #[Test] + public function nullInfoIsRejected(): void + { + self::assertNull(AdminBulkPage::moduleLogoUrl('quotes', null)); + } + + /** @return iterable */ + public static function badDirnames(): iterable + { + yield 'empty' => ['']; + yield 'whitespace only' => [' ']; + yield 'dot segment' => ['.']; + yield 'parent segment' => ['..']; + yield 'contains a dot' => ['foo.bar']; + yield 'contains a slash' => ['foo/bar']; + yield 'contains a backslash' => ['foo\\bar']; + yield 'contains a space' => ['foo bar']; + yield 'traversal' => ['../../etc']; + yield 'null byte' => ["quotes\0"]; + yield 'quote' => ["foo'); alert(1);//"]; + } + + #[Test] + #[DataProvider('badDirnames')] + public function dirnameOutsideTheAllowlistIsRejected(string $dirname): void + { + self::assertNull(AdminBulkPage::moduleLogoUrl($dirname, $this->info('logo.png'))); + } + + /** @return iterable */ + public static function badImageValues(): iterable + { + yield 'empty' => ['']; + yield 'whitespace only' => [' ']; + yield 'absolute posix path' => ['/etc/passwd']; + yield 'absolute after backslash normalisation' => ['\\windows\\system32\\x.png']; + yield 'parent traversal' => ['../../../mainfile.php']; + yield 'parent traversal mid-path' => ['assets/../../secret.png']; + yield 'dot segment' => ['./logo.png']; + yield 'empty segment' => ['assets//logo.png']; + yield 'null byte' => ["logo.png\0.txt"]; + yield 'newline' => ["logo\n.png"]; + yield 'control byte' => ["logo\x1F.png"]; + yield 'protocol' => ['https://evil.example/x.png']; + yield 'protocol relative' => ['//evil.example/x.png']; + } + + #[Test] + #[DataProvider('badImageValues')] + public function hostileImageValueIsRejected(string $image): void + { + self::assertNull(AdminBulkPage::moduleLogoUrl('quotes', $this->info($image))); + } + + /** + * A well-formed request for a module that is not on disk still resolves to null. + * + * The suffix is random per run, not a fixed literal: the point of the case is + * "a dirname that certainly does not exist", and a hard-coded name quietly + * stops testing that the day a fixture or a real module happens to use it. + */ + #[Test] + public function absentModuleDirectoryIsRejected(): void + { + $absent = 'no_such_module_' . \bin2hex(\random_bytes(16)); + + self::assertNull(AdminBulkPage::moduleLogoUrl($absent, $this->info('assets/logo.png'))); + } + + /** + * @return array{dirname:string,name:string,version:mixed,module_status:mixed,image:string,description:string} + */ + private function info(string $image): array + { + return [ + 'dirname' => 'quotes', + 'name' => 'Quotes', + 'version' => '1.0', + 'module_status' => 'Beta', + 'image' => $image, + 'description' => 'test', + ]; + } +} diff --git a/tests/Unit/class/LangTest.php b/tests/Unit/class/LangTest.php new file mode 100644 index 0000000..4b750fc --- /dev/null +++ b/tests/Unit/class/LangTest.php @@ -0,0 +1,232 @@ + %2$s', 'first', 'second') + ); + } + + /** + * A template that asks for more arguments than it is given throws on PHP 8. That + * must degrade to English, not take the page down. + */ + #[Test] + public function formatFallsBackWhenTheTranslationDemandsTooManyArguments(): void + { + \define('_AM_MODULEINSTALLER_TEST_FMT_GREEDY', 'wants %1$s and %2$s'); + + self::assertSame( + 'got one', + Lang::format('_AM_MODULEINSTALLER_TEST_FMT_GREEDY', 'got %s', 'one') + ); + } + + /** When even the English template disagrees, return it raw rather than nothing. */ + #[Test] + public function formatReturnsTheRawFallbackWhenBothTemplatesAreWrong(): void + { + \define('_AM_MODULEINSTALLER_TEST_FMT_BOTHBAD', 'wants %1$s and %2$s'); + + self::assertSame( + 'also wants %2$s', + Lang::format('_AM_MODULEINSTALLER_TEST_FMT_BOTHBAD', 'also wants %2$s', 'one') + ); + } + + /** + * core() reads the other domain but applies the same usability policy. With no + * bootable XOOPS the global file cannot load, which is exactly the situation the + * fallback is for — and it must return, not throw. + */ + #[Test] + public function coreConstantFallsBackWithoutABootedXoops(): void + { + self::assertSame('Yes', Lang::core('_AM_MODULEINSTALLER_TEST_CORE_MISSING', 'Yes')); + } + + #[Test] + public function coreAppliesTheSameBlanknessPolicy(): void + { + \define('_AM_MODULEINSTALLER_TEST_CORE_NBSP', self::NBSP); + + self::assertSame('No', Lang::core('_AM_MODULEINSTALLER_TEST_CORE_NBSP', 'No')); + } +} diff --git a/tests/Unit/class/Report/LegacyModuleReportAdapterTest.php b/tests/Unit/class/Report/LegacyModuleReportAdapterTest.php new file mode 100644 index 0000000..9ba4886 --- /dev/null +++ b/tests/Unit/class/Report/LegacyModuleReportAdapterTest.php @@ -0,0 +1,353 @@ +parse('')); + } + + #[Test] + public function plainTextBecomesOneInfoEvent(): void + { + $events = (new LegacyModuleReportAdapter())->parse('Module installed'); + + self::assertCount(1, $events); + self::assertSame('Module installed', $events[0]->plainText()); + self::assertSame(LogSeverity::Info, $events[0]->severity); + self::assertSame(0, $events[0]->depth); + } + + #[Test] + public function lineBreakingTagsSplitEvents(): void + { + $events = (new LegacyModuleReportAdapter())->parse('first
secondthird'); + + self::assertSame(['first', 'second', 'third'], $this->texts($events)); + } + + #[Test] + public function emphasisTagsBecomeEmphasisedFragments(): void + { + $events = (new LegacyModuleReportAdapter())->parse('table x_quotes created'); + + self::assertCount(1, $events); + self::assertSame('table x_quotes created', $events[0]->plainText()); + + $emphasised = \array_values(\array_filter( + $events[0]->fragments, + static fn (\XoopsModules\Moduleinstaller\Report\LogFragment $f): bool => $f->emphasised + )); + self::assertCount(1, $emphasised); + self::assertSame('x_quotes', $emphasised[0]->text); + } + + #[Test] + public function redSpanMarksTheLineAsError(): void + { + $events = (new LegacyModuleReportAdapter())->parse('could not add'); + + self::assertCount(1, $events); + self::assertSame(LogSeverity::Error, $events[0]->severity); + self::assertSame('could not add', $events[0]->plainText()); + } + + /** + * Core emits spans that wrap a
. Both halves are the same red run, so both + * lines are errors — this is why the depth counters live outside the line loop. + */ + #[Test] + public function errorSpanWrappingALineBreakColoursBothLines(): void + { + $events = (new LegacyModuleReportAdapter())->parse('before
after
'); + + self::assertSame(['before', 'after'], $this->texts($events)); + self::assertSame(LogSeverity::Error, $events[0]->severity); + self::assertSame(LogSeverity::Error, $events[1]->severity); + } + + #[Test] + public function unclosedErrorSpanColoursEveryFollowingLine(): void + { + $events = (new LegacyModuleReportAdapter())->parse('ok
bad
alsobad'); + + self::assertSame(['ok', 'bad', 'alsobad'], $this->texts($events)); + self::assertSame(LogSeverity::Info, $events[0]->severity); + self::assertSame(LogSeverity::Error, $events[1]->severity); + self::assertSame(LogSeverity::Error, $events[2]->severity); + } + + /** + * Core nests spans, and only the red one carries severity. Since every span + * shares one closing tag, an inner ordinary span's
must not be read + * as closing the red span around it — that silently ends the error run and + * repaints the rest of the transcript as ordinary output, which also drops + * the result from Warning back to Success. + */ + #[Test] + public function nestedOrdinarySpanDoesNotCloseTheEnclosingErrorSpan(): void + { + $events = (new LegacyModuleReportAdapter())->parse( + 'bad detail
still bad
' + ); + + self::assertSame(['bad detail', 'still bad'], self::texts($events)); + self::assertSame(LogSeverity::Error, $events[0]->severity); + self::assertSame( + LogSeverity::Error, + $events[1]->severity, + 'the red span is still open on line 2; the inner span closed itself' + ); + } + + /** Several ordinary spans, and one closing after the break, still pair correctly. */ + #[Test] + public function deeplyNestedOrdinarySpansPairWithThemselves(): void + { + $events = (new LegacyModuleReportAdapter())->parse( + 'ab
c
d' + ); + + self::assertSame(['ab', 'cd'], self::texts($events)); + self::assertSame(LogSeverity::Error, $events[0]->severity); + // The red span closes after "c", so "d" — outside it — is ordinary. Both + // facts have to hold at once for the pairing to be right rather than lucky. + self::assertSame(LogSeverity::Error, $events[1]->severity); + } + + /** An ordinary span opened before a break must not colour anything. */ + #[Test] + public function ordinarySpanAloneNeverProducesAnError(): void + { + $events = (new LegacyModuleReportAdapter())->parse('plain
also plain
'); + + self::assertSame(['plain', 'also plain'], self::texts($events)); + self::assertSame(LogSeverity::Info, $events[0]->severity); + self::assertSame(LogSeverity::Info, $events[1]->severity); + } + + /** A stray close tag must not drive the counter negative and invert the state. */ + #[Test] + public function strayClosingSpanDoesNotInvertSeverity(): void + { + $events = (new LegacyModuleReportAdapter())->parse('plain
stillplain'); + + self::assertSame(['plain', 'stillplain'], $this->texts($events)); + self::assertSame(LogSeverity::Info, $events[0]->severity); + self::assertSame(LogSeverity::Info, $events[1]->severity); + } + + #[Test] + public function nbspPairsBecomeDepthAndLeaveTheTextClean(): void + { + $events = (new LegacyModuleReportAdapter())->parse('  nested'); + + self::assertCount(1, $events); + self::assertSame(1, $events[0]->depth); + self::assertSame('nested', $events[0]->plainText()); + } + + #[Test] + public function fourNbspAreTwoLevelsOfDepth(): void + { + $events = (new LegacyModuleReportAdapter())->parse('    deeper'); + + self::assertSame(2, $events[0]->depth); + self::assertSame('deeper', $events[0]->plainText()); + } + + /** Only whole pairs are structure; the odd one is content and must survive. */ + #[Test] + public function oddNbspIsKeptAtTheHeadOfTheText(): void + { + $events = (new LegacyModuleReportAdapter())->parse('   odd'); + + self::assertSame(1, $events[0]->depth); + self::assertSame(self::NBSP . 'odd', $events[0]->plainText()); + } + + /** ASCII space is content, not nesting — core does not indent with it. */ + #[Test] + public function leadingAsciiSpaceIsContentNotIndent(): void + { + $events = (new LegacyModuleReportAdapter())->parse(' spaced'); + + self::assertSame(0, $events[0]->depth); + self::assertSame(' spaced', $events[0]->plainText()); + } + + /** + * The documented byte-vs-character trap. Every one of these characters encodes + * as 0xC2 followed by a continuation byte, so any byte-wise trim of the NBSP + * "\xC2\xA0" eats the lead byte and leaves an orphan — which surfaces as U+FFFD + * after escaping and makes json_encode() of the plain text return false. + * Translated packs use exactly these as bullets. + */ + #[Test] + public function latin1SupplementBulletsSurviveIndentExtraction(): void + { + foreach (['«', '»', '·', '¡', '¿', '°', '±'] as $bullet) { + $events = (new LegacyModuleReportAdapter())->parse('  ' . $bullet . ' item'); + + self::assertCount(1, $events, "bullet {$bullet}"); + self::assertSame(1, $events[0]->depth, "bullet {$bullet}"); + self::assertSame($bullet . ' item', $events[0]->plainText(), "bullet {$bullet}"); + self::assertTrue( + \mb_check_encoding($events[0]->plainText(), 'UTF-8'), + "bullet {$bullet} produced invalid UTF-8" + ); + self::assertNotFalse( + \json_encode($events[0]->plainText()), + "bullet {$bullet} is not JSON-encodable" + ); + } + } + + #[Test] + public function daggerAtEndOfLineSurvivesIntact(): void + { + // "†" is U+2020 = \xE2\x80\xA0 — it ENDS in the same byte as NBSP. + $events = (new LegacyModuleReportAdapter())->parse('note †'); + + self::assertSame('note †', $events[0]->plainText()); + self::assertTrue(\mb_check_encoding($events[0]->plainText(), 'UTF-8')); + } + + /** Markers are private to the parse; control bytes are stripped before they run. */ + #[Test] + public function markerBytesInInputCannotForgeSeverity(): void + { + $events = (new LegacyModuleReportAdapter())->parse("\x01Enot an error"); + + self::assertCount(1, $events); + self::assertSame(LogSeverity::Info, $events[0]->severity); + self::assertSame('Enot an error', $events[0]->plainText()); + self::assertStringNotContainsString("\x01", $events[0]->plainText()); + } + + #[Test] + public function scriptAndStyleContentIsDiscardedEntirely(): void + { + $events = (new LegacyModuleReportAdapter())->parse( + 'kept' + ); + + self::assertSame(['kept'], $this->texts($events)); + } + + #[Test] + public function crlfIsNormalisedAndLeavesNoStrayCarriageReturn(): void + { + $events = (new LegacyModuleReportAdapter())->parse("alpha\r\nbeta"); + + self::assertSame(['alpha', 'beta'], $this->texts($events)); + foreach ($events as $event) { + self::assertStringNotContainsString("\r", $event->plainText()); + } + } + + #[Test] + public function blankLinesAreDropped(): void + { + $events = (new LegacyModuleReportAdapter())->parse('one


two'); + + self::assertSame(['one', 'two'], $this->texts($events)); + } + + #[Test] + public function coreEscapingIsCollapsedExactlyOnce(): void + { + $events = (new LegacyModuleReportAdapter())->parse('Smith & Sons <tag>'); + + self::assertSame('Smith & Sons ', $events[0]->plainText()); + } + + /** + * The security claim of the whole feature, asserted end to end: hostile markup in + * core's log cannot become markup in the admin page. The transcript is data; the + * renderer writes every tag from a literal in its own file. + */ + #[Test] + public function hostileMarkupCannotSurviveIntoRenderedHtml(): void + { + $hostile = '' + . 'click' + . '' + . '" onmouseover="alert(4)'; + + $html = (new LogEventHtmlRenderer())->render( + (new LegacyModuleReportAdapter())->parse($hostile) + ); + + self::assertStringNotContainsString('render([])); + } + + #[Test] + public function singleInfoEventRendersPlainTextWithoutSpan(): void + { + $renderer = new LogEventHtmlRenderer(); + + $out = $renderer->render([LogEvent::text('hello world')]); + + self::assertSame('hello world', $out); + self::assertStringNotContainsString('render([LogEvent::text('done', LogSeverity::Success)]); + + self::assertSame('done', $out); + } + + #[Test] + public function warningWrapsInCorrectSpanClass(): void + { + $renderer = new LogEventHtmlRenderer(); + + $out = $renderer->render([LogEvent::text('careful', LogSeverity::Warning)]); + + self::assertSame('careful', $out); + } + + #[Test] + public function errorWrapsInCorrectSpanClass(): void + { + $renderer = new LogEventHtmlRenderer(); + + $out = $renderer->render([LogEvent::text('boom', LogSeverity::Error)]); + + self::assertSame('boom', $out); + } + + #[Test] + public function emphasisedFragmentRendersStrongInsideSpan(): void + { + $renderer = new LogEventHtmlRenderer(); + + $event = new LogEvent(LogSeverity::Success, [ + new LogFragment('table '), + new LogFragment('x8a9_quotes', true), + new LogFragment(' created'), + ], 0); + + $out = $renderer->render([$event]); + + self::assertSame( + 'table x8a9_quotes created', + $out + ); + } + + #[Test] + public function escapingHappensExactlyOnce(): void + { + $renderer = new LogEventHtmlRenderer(); + + $out = $renderer->render([LogEvent::text('x')]); + + self::assertSame('<b>x</b>', $out); + self::assertStringNotContainsString('', $out); + } + + #[Test] + public function attributeInjectionIsImpossible(): void + { + $renderer = new LogEventHtmlRenderer(); + + $out = $renderer->render([LogEvent::text('" onload="alert(1)', LogSeverity::Error)]); + + self::assertStringContainsString('"', $out); + self::assertStringNotContainsString('boom', + $out + ); + } + + #[Test] + public function twoEventsAreJoinedByBrAndNewline(): void + { + $renderer = new LogEventHtmlRenderer(); + + $out = $renderer->render([ + LogEvent::text('first'), + LogEvent::text('second'), + ]); + + self::assertSame("first
\nsecond", $out); + } +} diff --git a/tests/Unit/class/Report/ModuleOperationResultTest.php b/tests/Unit/class/Report/ModuleOperationResultTest.php new file mode 100644 index 0000000..9b25753 --- /dev/null +++ b/tests/Unit/class/Report/ModuleOperationResultTest.php @@ -0,0 +1,177 @@ +isOk()); + self::assertFalse($result->isSkipped()); + self::assertFalse($result->isFailed()); + self::assertSame(Outcome::Ok, $result->outcome); + self::assertNull($result->reason); + } + + #[Test] + public function skippedFactoryProducesSingleWarningEvent(): void + { + $result = ModuleOperationResult::skipped('quotes', 'install', 'already installed'); + + self::assertTrue($result->isSkipped()); + self::assertSame('already installed', $result->reason); + self::assertCount(1, $result->events); + self::assertSame(LogSeverity::Warning, $result->events[0]->severity); + self::assertSame('already installed', $result->events[0]->plainText()); + } + + #[Test] + public function failedFactoryPrependsReasonAsErrorEvent(): void + { + $transcript = [ + LogEvent::text('step one'), + LogEvent::text('step two'), + ]; + + $result = ModuleOperationResult::failed('quotes', 'install', 'install failed', $transcript); + + self::assertCount(3, $result->events); + self::assertSame(LogSeverity::Error, $result->events[0]->severity); + self::assertSame('install failed', $result->events[0]->plainText()); + self::assertSame('step one', $result->events[1]->plainText()); + self::assertSame('step two', $result->events[2]->plainText()); + } + + #[Test] + public function failedWithTranscriptDoesNotPrependOrDuplicate(): void + { + $transcript = [ + LogEvent::text('install failed', LogSeverity::Error), + LogEvent::text('step two'), + ]; + + $result = ModuleOperationResult::failedWithTranscript('quotes', 'install', $transcript); + + self::assertCount(2, $result->events); + self::assertSame('install failed', $result->reason); + self::assertSame($result->events[0]->plainText(), $result->reason); + + $occurrences = \substr_count($result->plainText(), 'install failed'); + self::assertSame(1, $occurrences); + } + + #[Test] + public function failedWithTranscriptEmptyArrayYieldsEmptyReason(): void + { + $result = ModuleOperationResult::failedWithTranscript('quotes', 'install', []); + + self::assertSame('', $result->reason); + self::assertSame([], $result->events); + } + + #[Test] + public function hasErrorEventsIsIndependentOfOutcome(): void + { + $okWithError = ModuleOperationResult::ok('quotes', 'install', [ + LogEvent::text('something odd', LogSeverity::Error), + ]); + self::assertTrue($okWithError->hasErrorEvents()); + + $failedAllInfo = ModuleOperationResult::failedWithTranscript('quotes', 'install', [ + LogEvent::text('just info', LogSeverity::Info), + ]); + self::assertFalse($failedAllInfo->hasErrorEvents()); + } + + #[Test] + public function severityFailedIsError(): void + { + $result = ModuleOperationResult::failed('quotes', 'install', 'boom'); + + self::assertSame(LogSeverity::Error, $result->severity()); + } + + #[Test] + public function severitySkippedIsWarning(): void + { + $result = ModuleOperationResult::skipped('quotes', 'install', 'already there'); + + self::assertSame(LogSeverity::Warning, $result->severity()); + } + + #[Test] + public function severityOkWithErrorEventIsWarning(): void + { + $result = ModuleOperationResult::ok('quotes', 'install', [ + LogEvent::text('odd but survived', LogSeverity::Error), + ]); + + self::assertSame(LogSeverity::Warning, $result->severity()); + } + + #[Test] + public function severityOkWithCleanEventsIsSuccess(): void + { + $result = ModuleOperationResult::ok('quotes', 'install', [ + LogEvent::text('all good', LogSeverity::Success), + ]); + + self::assertSame(LogSeverity::Success, $result->severity()); + } + + #[Test] + public function plainTextJoinsEventPlainTextWithNewline(): void + { + $result = ModuleOperationResult::ok('quotes', 'install', [ + LogEvent::text('first'), + LogEvent::text('second'), + ]); + + self::assertSame("first\nsecond", $result->plainText()); + } + + #[Test] + public function toArrayHasExactKeysAndEnumStringValues(): void + { + $result = ModuleOperationResult::failed('quotes', 'install', 'install failed', [ + LogEvent::text('step one', LogSeverity::Info, 1), + ]); + + $array = $result->toArray(); + + self::assertSame( + ['dirname', 'action', 'outcome', 'severity', 'reason', 'events'], + \array_keys($array) + ); + self::assertSame('quotes', $array['dirname']); + self::assertSame('install', $array['action']); + self::assertSame('fail', $array['outcome']); + self::assertSame('error', $array['severity']); + self::assertSame('install failed', $array['reason']); + + self::assertCount(2, $array['events']); + foreach ($array['events'] as $eventArray) { + self::assertSame(['severity', 'depth', 'text'], \array_keys($eventArray)); + } + + self::assertSame('error', $array['events'][0]['severity']); + self::assertSame(0, $array['events'][0]['depth']); + self::assertSame('install failed', $array['events'][0]['text']); + + self::assertSame('info', $array['events'][1]['severity']); + self::assertSame(1, $array['events'][1]['depth']); + self::assertSame('step one', $array['events'][1]['text']); + } +} diff --git a/xoops_version.php b/xoops_version.php index 1ec9be0..41e6a5c 100644 --- a/xoops_version.php +++ b/xoops_version.php @@ -17,9 +17,9 @@ $moduleDirName = basename(__DIR__); $modversion = []; -$modversion['version'] = '1.6.0-Stable'; -$modversion['module_status'] = ''; -$modversion['release_date'] = '2026/07/23'; +$modversion['version'] = '1.7.0-Alpha1'; +$modversion['module_status'] = 'Alpha 1'; +$modversion['release_date'] = '2026/07/30'; $modversion['name'] = _MI_MODULEINSTALLER_NAME; $modversion['description'] = _MI_MODULEINSTALLER_DESC; $modversion['author'] = 'Michael Beck';

"; - $content .= "" . $spanOpen; + $content .= "
"; + // alt='' for the same reason renderReport() uses it: the module name is + // rendered in the very next cell, so a described image makes a screen + // reader announce the name twice on every row. The logo is decorative. + $content .= '' !== $imgSrc + ? "" + : ""; + $content .= "" . $spanOpen; + // The version, the status and the folder path are an LTR run that may sit + // beside an RTL module name. Each is isolated so the parentheses and the + // digits cannot migrate across the boundary. $content .= ' ' . $nameEsc - . ' ' . \htmlspecialchars((string) $info['version'], \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') - . ' ' . \htmlspecialchars((string) $info['module_status'], \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') - . ' (folder: /' . \htmlspecialchars($info['dirname'], \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ')'; + . ' ' . \htmlspecialchars((string) $info['version'], \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '' + . ' ' . \htmlspecialchars((string) $info['module_status'], \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . '' + . ' (' . \htmlspecialchars( + Lang::format('_AM_MODULEINSTALLER_FOLDER_LABEL', 'folder: /%s', $info['dirname']), + \ENT_QUOTES | \ENT_SUBSTITUTE, + 'UTF-8' + ) . ')'; $content .= '
' . \htmlspecialchars($info['description'], \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8') . ''; $content .= $spanClose . "