From 8bc485441c3d4eb385be265827f6dec7d3a5bc72 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Sat, 1 Aug 2026 04:32:22 -0400 Subject: [PATCH 1/6] fix(editor): repair the dhtml toolbar's no-selection actions Clicking Size, Font, Colour or B/I/U/S with no text selected did nothing at all. The no-selection branch called setVisible() on an element with the id held in $_hiddenText (default xoopsHiddenText), but no renderer has ever emitted such an element, so xoopsGetElementById() returned null and the handler threw a TypeError before it could act. For xoopsSetElementAttribute() that branch was dead twice over: besides the missing element, it eval'd a setElementSize/Font/Color() helper and no setElement* function exists in the file. It is removed, so with no selection the tag pair is simply inserted at the caret, which is what clicking the button asks for. xoopsMakeStyle() keeps its branch for any custom renderer that does emit the element, but only takes it when the element and the handler both genuinely exist, and otherwise falls through to inserting the pair. Both eval() calls are gone with it. The remaining dispatch is an explicit map of the four style helpers, which is all the callers ever pass. xoopsGetSelect() returns null on browsers without a selection API, so the selection is normalised to an empty string before use: without that, removing the old length guard would have inserted the literal text "null" between the tags. xoopsCodeText() is a leftover from an older toolbar layout that had a separate "add text" box. Nothing in core or any bundled extension calls it and no renderer emits the elements it reads, so every path through it dereferenced null. It is kept, because it is a global a third-party module could still call, but now returns harmlessly instead of throwing. --- htdocs/include/formdhtmltextarea.js | 53 ++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/htdocs/include/formdhtmltextarea.js b/htdocs/include/formdhtmltextarea.js index e936ff8af..65f0d7590 100644 --- a/htdocs/include/formdhtmltextarea.js +++ b/htdocs/include/formdhtmltextarea.js @@ -131,13 +131,23 @@ function xoopsCodeCode(id, enterCodePhrase) { domobj.focus(); } +// Legacy helper from an older toolbar layout that had a separate "add text" box alongside +// the Font/Colour/Size selects. No renderer has emitted an "Addtext" element or an +// element with the hiddentext id for a long time, and nothing in core or any bundled +// extension calls this function, so every path through it dereferenced null. It is kept +// only because it is a global that a third-party module could still call; it now returns +// harmlessly instead of throwing a TypeError. function xoopsCodeText(id, hiddentext, enterTextboxPhrase) { var textareaDom = xoopsGetElementById(id); var textDom = xoopsGetElementById(id + "Addtext"); var fontDom = xoopsGetElementById(id + "Font"); var colorDom = xoopsGetElementById(id + "Color"); var sizeDom = xoopsGetElementById(id + "Size"); - var xoopsHiddenTextDomStyle = xoopsGetElementById(hiddentext).style; + var hiddenDom = xoopsGetElementById(hiddentext); + if (!textareaDom || !textDom || !fontDom || !colorDom || !sizeDom || !hiddenDom) { + return; + } + var xoopsHiddenTextDomStyle = hiddenDom.style; var selection = xoopsGetSelect(id); if (selection.length > 0) { var textDomValue = selection; @@ -223,12 +233,19 @@ function xoopsGetSelect(id) { function xoopsSetElementAttribute(key, val, id, eid) { + // xoopsGetSelect() returns null on browsers with no selection API (its final else + // branch), so normalise before use: otherwise text.length throws and the tag pair + // below would contain the literal string "null". var text = xoopsGetSelect(id); - if (text.length <= 0) { - setVisible("xoopsHiddenText"); - eval("setElement" + key.substr(0, 1).toUpperCase() + key.substr(1, key.length) + "(eid, val)"); - return; - } + if (text === null || text === undefined) { + text = ""; + } + // With no selection this used to call setVisible("xoopsHiddenText") and then eval a + // setElementSize/Font/Color() helper. That path was dead twice over: no renderer emits + // an element with that id (so setVisible() threw a TypeError on null), and no + // setElement* function has ever existed in this file. Size/Font/Colour therefore did + // nothing at all unless text was selected. Insert an empty tag pair instead, which is + // the behaviour every other editor has and what the user is asking for by clicking. var domobj = xoopsGetElementById(id); xoopsInsertText(domobj, "[" + key + "=" + val + "]" + text + "[/" + key + "]"); domobj.focus(); @@ -270,11 +287,31 @@ function makeLineThrough(id) { } } +// Explicit dispatch for the legacy live-preview helpers. Replaces an eval() of a +// caller-supplied function name: only these four are ever valid targets, so a lookup is +// both safer and clearer than evaluating a string. +var xoopsStylePreviewFuncs = { + makeBold: makeBold, + makeItalic: makeItalic, + makeUnderline: makeUnderline, + makeLineThrough: makeLineThrough +}; + function xoopsMakeStyle(id, eid, val, func) { + // See xoopsSetElementAttribute(): xoopsGetSelect() can return null. var text = xoopsGetSelect(id); - if (text.length <= 0 && func.length > 0 && eid.length > 0) { + if (text === null || text === undefined) { + text = ""; + } + // The no-selection branch toggles a hidden live-preview element. No current renderer + // emits one, so setVisible() dereferenced null and threw — B/I/U/S did nothing unless + // text was selected. Take it only when the element genuinely exists (kept for any + // custom renderer that still emits it); otherwise fall through and insert an empty + // tag pair so the click does something useful. + if (text.length <= 0 && func.length > 0 && eid.length > 0 + && xoopsGetElementById(eid) && xoopsStylePreviewFuncs[func]) { setVisible(eid); - eval(func + "(eid)"); + xoopsStylePreviewFuncs[func](eid); return; } var domobj = xoopsGetElementById(id); From 4ce640f726ba1ff1336378c2b5a1501f53ed5f1c Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Sat, 1 Aug 2026 04:32:25 -0400 Subject: [PATCH 2/6] refactor(editor): render one shared dhtml toolbar for every renderer The same editor showed a different toolbar in the control panel than on the front end, and different again between front-end themes. The cause was not CSS: renderFormDhtmlTAXoopsCode() and renderFormDhtmlTATypography() were independently hand-written HTML in five renderers, and no admin theme selects a renderer, so the control panel silently fell back to the Legacy one whose Bootstrap class names no admin theme defines. XoopsDhtmlToolbar now produces the toolbar once and all five renderers delegate to it. Only renderFormDhtmlTextArea() is on XoopsFormRendererInterface; the other two are protected helpers, so they are kept as thin delegates for any third-party subclass that calls or overrides them. Each renderer still supplies its own chrome -- the textarea, the preview fieldset and the script loader. The markup is framework-neutral with its own xo-edtb-* classes and a self-contained stylesheet, because the admin themes load no CSS framework at all; every colour, radius and size is a custom property so a theme can restyle it without touching markup. Dropdowns are native
, grouped per textarea with name= so opening one closes the others, with a small script for older engines plus click-outside and Escape handling. The typography row is emitted server-side rather than written by document.write, and the colour list is a curated palette in a class constant instead of 216 entries generated by a nested loop. role="toolbar" and role="group" are now present, which only the Bootstrap 5 renderer had. Four contracts are preserved deliberately. The codeicon preload event still fires with $code by reference at the same point, so modules can keep appending buttons. TextSanitizer extensions keep their [$html, $js] return shape, and the three divergent rewrites of their hardcoded btn-default classes become one. The global JS function names are untouched, since extensions call them by name. XoopsFormDhtmlTextArea's public surface is unchanged. The stylesheet also restores resize:vertical on the editor's textarea: the default and transition admin themes ship a universal `* { resize:none }` reset which stripped the grip, so the control panel's editor could not be resized while the front end's could. A test asserts the substantive property: all five renderers emit an identical toolbar for the same element. --- .../dhtmltextarea/XoopsDhtmlToolbar.php | 398 ++++++++++++++++++ .../dhtmltextarea/assets/toolbar.css | 193 +++++++++ .../dhtmltextarea/assets/toolbar.js | 71 ++++ .../renderer/XoopsFormRendererBootstrap3.php | 146 +------ .../renderer/XoopsFormRendererBootstrap4.php | 149 +------ .../renderer/XoopsFormRendererBootstrap5.php | 149 +------ .../renderer/XoopsFormRendererLegacy.php | 101 +---- .../renderer/XoopsFormRendererTailwind.php | 136 +----- tests/bootstrap.php | 16 + .../class/xoopsform/XoopsDhtmlToolbarTest.php | 349 +++++++++++++++ 10 files changed, 1106 insertions(+), 602 deletions(-) create mode 100644 htdocs/class/xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php create mode 100644 htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css create mode 100644 htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.js create mode 100644 tests/unit/htdocs/class/xoopsform/XoopsDhtmlToolbarTest.php diff --git a/htdocs/class/xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php b/htdocs/class/xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php new file mode 100644 index 000000000..7ebc34ea4 --- /dev/null +++ b/htdocs/class/xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php @@ -0,0 +1,398 @@ +`, the preview + * fieldset, and the JS loader shim). + * + * The markup is framework-neutral: it uses its own `xo-edtb-*` classes (styled by + * `assets/toolbar.css`, driven by CSS custom properties) instead of Bootstrap/DaisyUI classes, so + * it renders identically whether or not a CSS framework is loaded — the admin themes load none. + * + * {@see self::renderCodeButtons()}, {@see self::renderTypography()} and + * {@see self::renderCheckLength()} are `public`, not `protected`: five unrelated renderer classes + * (not subclasses of this one) need to call them directly to build their thin + * `renderFormDhtmlTAXoopsCode()`/`renderFormDhtmlTATypography()` delegates, and PHP's `protected` + * only allows access from the same class or a subclass. They remain overridable by a subclass that + * wants to adjust a single row. + * + * @category XoopsEditor + * @package XoopsDhtmlToolbar + * @author XOOPS Project + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +class XoopsDhtmlToolbar +{ + /** @var string Base button class */ + private const BTN = 'xo-edtb-btn'; + + /** @var string Small button class (button row / dropdown toggles) */ + private const BTN_SM = 'xo-edtb-btn xo-edtb-btn-sm'; + + /** @var string Class for a role="group" cluster of related buttons */ + private const GROUP_CLASS = 'xo-edtb-group'; + + /** @var string Class for the outer role="toolbar" container */ + private const TOOLBAR_CLASS = 'xo-edtb-toolbar'; + + /** @var string Class for a
based dropdown */ + private const DROPDOWN_CLASS = 'xo-edtb-dropdown'; + + /** @var string Class for a dropdown's
    menu */ + private const MENU_CLASS = 'xo-edtb-menu'; + + /** @var string Class for a dropdown menu item */ + private const MENU_ITEM_CLASS = 'xo-edtb-menu-item'; + + /** @var string Class for the small colour preview swatch in the colour dropdown */ + private const SWATCH_CLASS = 'xo-edtb-swatch'; + + /** @var string aria-label for the outer toolbar (deliberately not localized — see report) */ + private const ARIA_TOOLBAR = 'Editor toolbar'; + + /** + * Default fonts used when $GLOBALS['formtextdhtml_fonts'] is not set, matching the default + * every renderer used before this refactor. + * + * @var string[] + */ + private const DEFAULT_FONTS = [ + 'Arial', + 'Courier', + 'Georgia', + 'Helvetica', + 'Impact', + 'Verdana', + 'Haettenschweiler', + ]; + + /** + * Curated colour palette: 'Display name' => 'hex value'. + * + * Legacy's original color dropdown generated a 216-entry list of every 6x6x6 web-safe hex + * value via a nested JavaScript loop and `document.write` — unusable in a dropdown and not + * server-rendered. This is a deliberate reduction to 11 curated, editable colours (the palette + * already used by three of the five renderers before this refactor). + * + * @var array + */ + private const COLOR_PALETTE = [ + 'Black' => '000000', + 'Blue' => '38AAFF', + 'Brown' => '987857', + 'Green' => '79D271', + 'Grey' => '888888', + 'Orange' => 'FFA700', + 'Paper' => 'E0E0E0', + 'Purple' => '363E98', + 'Red' => 'FF211E', + 'White' => 'FEFEFE', + 'Yellow' => 'FFD628', + ]; + + /** + * Guards stylesheet injection so N editors on a single page only add the ``/theme + * stylesheet reference once. + * + * @var bool + */ + private static $styleInjected = false; + + /** + * Render the complete DHTML editor toolbar: the xoopscode button row, the typography row, + * and the check-length button, wrapped in a single `role="toolbar"` container. + * + * @param XoopsFormDhtmlTextArea $element form element the toolbar acts on + * + * @return string rendered toolbar HTML + */ + public function render(XoopsFormDhtmlTextArea $element): string + { + $ret = $this->injectStylesheet(); + $ret .= ''; + + return $ret; + } + + /** + * Render the xoopscode button row (URL, email, image, image manager, smilies, TextSanitizer + * extension buttons, code, quote), and fire the `codeicon` preload event. + * + * @param XoopsFormDhtmlTextArea $element form element (its ->js may be appended to by + * TextSanitizer extensions) + * + * @return string rendered button group + */ + public function renderCodeButtons(XoopsFormDhtmlTextArea $element): string + { + $textareaId = $element->getName(); + $btn = self::BTN_SM; + + $code = ''; + $code .= '
    '; + $code .= $this->button($btn, $this->jsCall('xoopsCodeUrl', [$textareaId, _ENTERURL, _ENTERWEBTITLE]), _XOOPS_FORM_ALT_URL, 'fa-solid fa-link'); + $code .= $this->button($btn, $this->jsCall('xoopsCodeEmail', [$textareaId, _ENTEREMAIL, _ENTERWEBTITLE]), _XOOPS_FORM_ALT_EMAIL, 'fa-solid fa-envelope'); + $code .= $this->button($btn, $this->jsCall('xoopsCodeImg', [$textareaId, _ENTERIMGURL, _ENTERIMGPOS, _IMGPOSRORL, _ERRORIMGPOS, _XOOPS_FORM_ALT_ENTERWIDTH]), _XOOPS_FORM_ALT_IMG, 'fa-solid fa-file-image'); + $code .= $this->button($btn, $this->jsCall('openWithSelfMain', [XOOPS_URL . '/imagemanager.php?target=' . $textareaId, 'imgmanager', 400, 430]), _XOOPS_FORM_ALT_IMAGE, 'fa-solid fa-file-image', ' Manager'); + $code .= $this->button($btn, $this->jsCall('openWithSelfMain', [XOOPS_URL . '/misc.php?action=showpopups&type=smilies&target=' . $textareaId, 'smilies', 300, 475]), _XOOPS_FORM_ALT_SMILEY, 'fa-solid fa-face-smile'); + + $myts = \MyTextSanitizer::getInstance(); + $extensions = array_filter($myts->config['extensions']); + foreach (array_keys($extensions) as $key) { + $extension = $myts->loadExtension($key); + if (!$extension) { + continue; + } + $result = $extension->encode($textareaId); + $encode = $result[0] ?? ''; + $js = $result[1] ?? ''; + if (empty($encode)) { + continue; + } + // Contract: TextSanitizer extensions hardcode 'btn btn-default btn-sm' in their own + // encode() output — rewrite it onto our neutral classes here, once, rather than each + // renderer having its own (previously inconsistent) rewrite rule. + $code .= $this->rewriteExtensionButtonClasses($encode); + if (!empty($js)) { + $element->js .= $js; + } + } + + $code .= $this->button($btn, $this->jsCall('xoopsCodeCode', [$textareaId, _ENTERCODE]), _XOOPS_FORM_ALT_CODE, 'fa-solid fa-code'); + $code .= $this->button($btn, $this->jsCall('xoopsCodeQuote', [$textareaId, _ENTERQUOTE]), _XOOPS_FORM_ALT_QUOTE, 'fa-solid fa-quote-right'); + $code .= '
    '; + + // Contract: fired by reference so third-party modules can append buttons, at the same + // logical point as before — after the core buttons and the extensions, immediately before + // the code row is returned. + $xoopsPreload = \XoopsPreload::getInstance(); + $xoopsPreload->triggerEvent('core.class.xoopsform.formdhtmltextarea.codeicon', [&$code]); + + return $code; + } + + /** + * Render the typography row: size / font / colour dropdowns, then bold / italic / underline / + * strikethrough and left / center / right alignment button groups. + * + * @param XoopsFormDhtmlTextArea $element form element + * + * @return string rendered typography groups + */ + public function renderTypography(XoopsFormDhtmlTextArea $element): string + { + $textareaId = $element->getName(); + $hiddenText = (string) $element->_hiddenText; + + $sizes = $GLOBALS['formtextdhtml_sizes'] ?? []; + $fonts = !empty($GLOBALS['formtextdhtml_fonts']) ? $GLOBALS['formtextdhtml_fonts'] : self::DEFAULT_FONTS; + + $ret = '
    '; + $ret .= $this->dropdown($textareaId, $hiddenText, 'size', _SIZE, 'fa-solid fa-text-height', $sizes); + $ret .= $this->dropdown($textareaId, $hiddenText, 'font', _FONT, 'fa-solid fa-font', array_combine($fonts, $fonts)); + $ret .= $this->dropdown($textareaId, $hiddenText, 'color', _COLOR, 'fa-solid fa-palette', array_flip(self::COLOR_PALETTE), true); + $ret .= '
    '; + + $btn = self::BTN_SM; + $ret .= '
    '; + $ret .= $this->button($btn, $this->jsCall('xoopsMakeBold', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_BOLD, 'fa-solid fa-bold'); + $ret .= $this->button($btn, $this->jsCall('xoopsMakeItalic', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_ITALIC, 'fa-solid fa-italic'); + $ret .= $this->button($btn, $this->jsCall('xoopsMakeUnderline', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_UNDERLINE, 'fa-solid fa-underline'); + $ret .= $this->button($btn, $this->jsCall('xoopsMakeLineThrough', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_LINETHROUGH, 'fa-solid fa-strikethrough'); + $ret .= '
    '; + + $ret .= '
    '; + $ret .= $this->button($btn, $this->jsCall('xoopsMakeLeft', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_LEFT, 'fa-solid fa-align-left'); + $ret .= $this->button($btn, $this->jsCall('xoopsMakeCenter', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_CENTER, 'fa-solid fa-align-center'); + $ret .= $this->button($btn, $this->jsCall('xoopsMakeRight', [$hiddenText, $textareaId]), _XOOPS_FORM_ALT_RIGHT, 'fa-solid fa-align-right'); + $ret .= '
    '; + + return $ret; + } + + /** + * Render the check-length button, reading `configs['maxlength']` off the element the same way + * every renderer did before this refactor. + * + * @param XoopsFormDhtmlTextArea $element form element + * + * @return string rendered check-length button group + */ + public function renderCheckLength(XoopsFormDhtmlTextArea $element): string + { + $maxlength = 0; + // 'configs' is a legacy dynamic property some callers set on the element; guard the + // access so PHP 8.2+ does not warn when it was never set. + if (property_exists($element, 'configs') && is_array($element->configs) && isset($element->configs['maxlength'])) { + $maxlength = (int) $element->configs['maxlength']; + } + + $onclick = $this->jsCall('XoopsCheckLength', [$element->getName(), (string) $maxlength, _XOOPS_FORM_ALT_LENGTH, _XOOPS_FORM_ALT_LENGTH_MAX]); + + return '
    ' + . $this->button(self::BTN_SM, $onclick, _XOOPS_FORM_ALT_CHECKLENGTH, 'fa-solid fa-square-check') + . '
    '; + } + + /** + * Rewrite the class attribute TextSanitizer extensions hardcode ('btn btn-default btn-sm' or + * 'btn btn-default') onto this toolbar's neutral classes. Also catches the per-renderer + * remaps that existed before this refactor ('btn-secondary'), so extension output is + * consistent no matter which renderer/version produced it. + * + * @param string $html extension-supplied button HTML + * + * @return string HTML with neutral classes substituted in + */ + protected function rewriteExtensionButtonClasses(string $html): string + { + return str_replace( + ['btn btn-default btn-sm', 'btn btn-default', 'btn-secondary', 'btn-default'], + [self::BTN_SM, self::BTN, self::BTN, self::BTN], + $html + ); + } + + /** + * Render a single toolbar button. + * + * @param string $class button CSS class(es) + * @param string $onclickJs JavaScript expression for the onclick handler (already a + * complete statement, e.g. from {@see self::jsCall()}) + * @param string $title tooltip text (escaped internally) + * @param string $iconClass Font Awesome icon class string + * @param string $trailingHtml optional pre-built HTML appended after the icon + * + * @return string rendered '; + } + + /** + * Render a single `
    `/`` dropdown (size, font, or colour). Using native + * `
    ` keeps the toolbar framework-neutral and script-free for the menu interaction + * itself — no Bootstrap/DaisyUI JS or data-attributes are required. + * + * @param string $textareaId id of the textarea the dropdown acts on + * @param string $hiddenText hidden-text element id passed to xoopsSetElementAttribute + * @param string $attr attribute name passed to xoopsSetElementAttribute ('size'|'font'|'color') + * @param string $label dropdown button tooltip / aria-label + * @param string $iconClass Font Awesome icon class string for the toggle button + * @param array $options attrValue => display label + * @param bool $isColor when true, render a colour swatch (attrValue is a hex string) + * + * @return string rendered dropdown + */ + protected function dropdown(string $textareaId, string $hiddenText, string $attr, string $label, string $iconClass, array $options, bool $isColor = false): string + { + $escapedLabel = htmlspecialchars($label, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // name= makes sibling
    mutually exclusive natively (HTML "exclusive accordion"), + // so opening Font closes Size with no script at all. Scoped per textarea id so two + // editors on one page do not close each other's menus. assets/toolbar.js provides the + // same behaviour for browsers that predate the attribute, plus click-outside/Escape. + $groupName = 'xo-edtb-' . preg_replace('/[^A-Za-z0-9_-]/', '', $textareaId); + + $ret = '
    ' + . '' + . '' + . '
      '; + + foreach ($options as $attrValue => $display) { + $onclick = $this->jsCall('xoopsSetElementAttribute', [$attr, (string) $attrValue, $textareaId, $hiddenText]); + $escapedDisplay = htmlspecialchars((string) $display, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $swatch = ''; + if ($isColor) { + $swatch = ''; + } + $ret .= '
    • ' . $swatch . $escapedDisplay . '
    • '; + } + + $ret .= '
    '; + + return $ret; + } + + /** + * Build a JavaScript function-call expression as a complete statement (trailing `;`), string + * arguments HTML-attribute-escaped so the result is safe inside a single-quoted `onclick='...'` + * attribute. Integer/float arguments are emitted unquoted. + * + * @param string $fn JavaScript function name (hard-coded literal — never + * build this from user input) + * @param array $args arguments, in order + * + * @return string JavaScript statement, e.g. `xoopsCodeUrl("id", "Enter URL");` + */ + protected function jsCall(string $fn, array $args): string + { + $parts = []; + foreach ($args as $arg) { + if (is_int($arg) || is_float($arg)) { + $parts[] = (string) $arg; + } else { + $parts[] = '"' . htmlspecialchars((string) $arg, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"'; + } + } + + return $fn . '(' . implode(', ', $parts) . ');'; + } + + /** + * Inject the toolbar stylesheet once per request: via `$xoTheme->addStylesheet()` when a theme + * is available, otherwise as an inline `` tag — mirroring how the renderers already fall + * back for `image.js`. Guarded by a static flag so N editors on one page inject it once. + * + * @return string a `` tag when no theme is available, otherwise an empty string + */ + protected function injectStylesheet(): string + { + if (self::$styleInjected) { + return ''; + } + self::$styleInjected = true; + + $href = 'class/xoopseditor/dhtmltextarea/assets/toolbar.css'; + $src = 'class/xoopseditor/dhtmltextarea/assets/toolbar.js'; + + if (!empty($GLOBALS['xoTheme']) && is_object($GLOBALS['xoTheme'])) { + $GLOBALS['xoTheme']->addStylesheet($href); + $GLOBALS['xoTheme']->addScript($src); + + return ''; + } + + return '' . "\n" + . '' . "\n"; + } +} diff --git a/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css new file mode 100644 index 000000000..a63f6298e --- /dev/null +++ b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css @@ -0,0 +1,193 @@ +/** + * Self-contained styling for the shared XOOPS DHTML editor toolbar (XoopsDhtmlToolbar). + * + * Framework-neutral: every rule targets the `xo-edtb-*` prefix only, so this renders the same + * whether or not Bootstrap/Tailwind/DaisyUI is loaded — the admin themes load no CSS framework. + * Every colour, radius, spacing and size is a CSS custom property declared on the toolbar root, + * so a theme can restyle the toolbar by overriding variables instead of rewriting rules. + * + * You may not change or alter any portion of this comment or credits of supporting developers + * from this source code or any supporting source code which is considered copyrighted (c) + * material of the original comment or credit authors. This program is distributed in the hope + * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + */ + +.xo-edtb-toolbar { + --xo-edtb-bg: #f8f9fa; + --xo-edtb-border: #ced4da; + --xo-edtb-radius: 4px; + --xo-edtb-gap: 4px; + --xo-edtb-padding: 4px; + --xo-edtb-font-size: 0.8125rem; + + --xo-edtb-btn-size: 1.75rem; + --xo-edtb-btn-bg: #ffffff; + --xo-edtb-btn-fg: #212529; + --xo-edtb-btn-border: #ced4da; + --xo-edtb-btn-hover-bg: #e9ecef; + --xo-edtb-btn-active-bg: #dee2e6; + + --xo-edtb-menu-bg: #ffffff; + --xo-edtb-menu-fg: #212529; + --xo-edtb-menu-border: #ced4da; + --xo-edtb-menu-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--xo-edtb-gap); + padding: var(--xo-edtb-padding); + background: var(--xo-edtb-bg); + border: 1px solid var(--xo-edtb-border); + border-radius: var(--xo-edtb-radius); + font-size: var(--xo-edtb-font-size); + line-height: 1; +} + +.xo-edtb-toolbar, +.xo-edtb-toolbar * { + box-sizing: border-box; +} + +.xo-edtb-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--xo-edtb-gap); +} + +.xo-edtb-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-width: var(--xo-edtb-btn-size); + height: var(--xo-edtb-btn-size); + padding: 0 0.4rem; + background: var(--xo-edtb-btn-bg); + color: var(--xo-edtb-btn-fg); + border: 1px solid var(--xo-edtb-btn-border); + border-radius: var(--xo-edtb-radius); + font-size: inherit; + line-height: 1; + cursor: pointer; + user-select: none; +} + +.xo-edtb-btn:hover { + background: var(--xo-edtb-btn-hover-bg); +} + +.xo-edtb-btn:active { + background: var(--xo-edtb-btn-active-bg); +} + +.xo-edtb-btn-sm { + padding: 0 0.3rem; +} + +/* Dropdowns are native
    / — no JS required for the open/close interaction. */ +.xo-edtb-dropdown { + position: relative; +} + +.xo-edtb-dropdown > summary.xo-edtb-btn { + list-style: none; +} + +.xo-edtb-dropdown > summary::-webkit-details-marker { + display: none; +} + +.xo-edtb-dropdown > summary::marker { + content: ''; +} + +.xo-edtb-menu { + position: absolute; + z-index: 1000; + top: 100%; + left: 0; + margin: 2px 0 0; + padding: 4px 0; + min-width: 9rem; + max-height: 16rem; + overflow-y: auto; + list-style: none; + background: var(--xo-edtb-menu-bg); + color: var(--xo-edtb-menu-fg); + border: 1px solid var(--xo-edtb-menu-border); + border-radius: var(--xo-edtb-radius); + box-shadow: var(--xo-edtb-menu-shadow); +} + +/* + * Restore the resize grip on the editor's textarea. + * + * The "default" and "transition" ADMIN themes ship a universal reset — + * `* { … resize:none; }` (modules/system/themes/{default,transition}/css/reset.css:13) — which + * strips the grip from every element on the page, so the control panel's editor could not be + * resized while the front end's could. The textarea is rendered as a sibling immediately after + * the toolbar, so this sibling selector reaches it without needing a class on the element (which + * would mean touching all five renderers). Specificity (0,1,1) comfortably beats `*` (0,0,0). + */ +.xo-edtb-toolbar ~ textarea { + resize: vertical; +} + +/* + * Reset the list markers on the ITEMS, not just the list. The admin "default" theme ships a + * bare `li { list-style: square inside; }` (modules/system/themes/default/css/style.css:59-62), + * which applies directly to the
  • and therefore beats a `list-style: none` set only on the + * parent
      . That is why square bullets appeared in the control panel but not on the front + * end. `.xo-edtb-menu > li` (0,1,1) outranks a bare `li` (0,0,1) without needing !important. + */ +.xo-edtb-menu > li { + list-style: none; + margin: 0; + padding: 0; +} + +.xo-edtb-menu-item { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.6rem; + color: inherit; + text-decoration: none; + white-space: nowrap; +} + +.xo-edtb-menu-item:hover { + background: var(--xo-edtb-btn-hover-bg); +} + +.xo-edtb-swatch { + display: inline-block; + width: 0.9rem; + height: 0.9rem; + border: 1px solid var(--xo-edtb-menu-border); + border-radius: 2px; +} + +@media (prefers-color-scheme: dark) { + .xo-edtb-toolbar { + --xo-edtb-bg: #2b2f33; + --xo-edtb-border: #495057; + + --xo-edtb-btn-bg: #343a40; + --xo-edtb-btn-fg: #e9ecef; + --xo-edtb-btn-border: #495057; + --xo-edtb-btn-hover-bg: #495057; + --xo-edtb-btn-active-bg: #5a6268; + + --xo-edtb-menu-bg: #343a40; + --xo-edtb-menu-fg: #e9ecef; + --xo-edtb-menu-border: #495057; + --xo-edtb-menu-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + } +} diff --git a/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.js b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.js new file mode 100644 index 000000000..fd8142346 --- /dev/null +++ b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.js @@ -0,0 +1,71 @@ +/* + * Shared DHTML editor toolbar behaviour. + * + * The dropdowns are native
      /, and each toolbar's set shares a name= so modern + * browsers make them mutually exclusive on their own. This file supplies the rest: + * + * 1. the same exclusivity for browsers that predate the name= "exclusive accordion" attribute + * (name= on
      is only supported in newer engines); + * 2. closing the open menu when the user clicks anywhere outside it; + * 3. closing it on Escape, and returning focus to the toggle. + * + * Everything is delegated from document, so toolbars added to the page later (AJAX forms, a + * second editor) are handled without re-initialising anything. + */ +(function () { + 'use strict'; + + if (window.xoopsEditorToolbarInit) { + return; + } + window.xoopsEditorToolbarInit = true; + + var DROPDOWN = 'details.xo-edtb-dropdown'; + + /** + * Close every open dropdown except the one passed in. + * + * @param {Element|null} keepOpen dropdown to leave alone + * @param {Element|null} scope toolbar to limit the sweep to, or null for the document + */ + function closeOthers(keepOpen, scope) { + var root = scope || document; + var open = root.querySelectorAll(DROPDOWN + '[open]'); + for (var i = 0; i < open.length; i++) { + if (open[i] !== keepOpen) { + open[i].removeAttribute('open'); + } + } + } + + // 1 + 2: opening one closes the others. 'toggle' fires on open AND close, so only act on open. + document.addEventListener('toggle', function (event) { + var details = event.target; + if (!details || !details.matches || !details.matches(DROPDOWN) || !details.hasAttribute('open')) { + return; + } + // Limit to the owning toolbar so a second editor on the page is unaffected. + closeOthers(details, details.closest('.xo-edtb-toolbar')); + }, true); // capture: 'toggle' does not bubble + + // 2: click anywhere outside an open dropdown closes it. + document.addEventListener('click', function (event) { + var inside = event.target.closest ? event.target.closest(DROPDOWN) : null; + closeOthers(inside, null); + }); + + // 3: Escape closes the open dropdown and puts focus back on its toggle. + document.addEventListener('keydown', function (event) { + if (event.key !== 'Escape' && event.keyCode !== 27) { + return; + } + var open = document.querySelectorAll(DROPDOWN + '[open]'); + for (var i = 0; i < open.length; i++) { + var summary = open[i].querySelector('summary'); + open[i].removeAttribute('open'); + if (summary && typeof summary.focus === 'function') { + summary.focus(); + } + } + }); +})(); diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php index 8eb88ed7e..44862bfac 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php @@ -9,6 +9,7 @@ */ require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; +require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; /** * Bootstrap3 style form renderer @@ -229,13 +230,9 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) { xoops_loadLanguage('formdhtmltextarea'); $ret = ''; - // actions - $ret .= $this->renderFormDhtmlTAXoopsCode($element) . "
      \n"; - // fonts - $ret .= $this->renderFormDhtmlTATypography($element); - // length checker - - $ret .= "
      \n"; + // toolbar: xoopscode buttons, typography, check-length — shared across all renderers + $toolbar = new \XoopsDhtmlToolbar(); + $ret .= $toolbar->render($element) . "
      \n"; // the textarea box $ret .= "
      \n"; @@ -200,101 +197,35 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) /** * Render xoopscode buttons for editor, include calling text sanitizer extensions * + * Thin delegate to the shared {@see XoopsDhtmlToolbar}. Kept (rather than removed) because + * this method is `protected`, not part of {@see XoopsFormRendererInterface}, and a third-party + * subclass of this renderer may still call or override it. + * * @param XoopsFormDhtmlTextArea $element form element * * @return string rendered buttons for xoopscode assistance */ protected function renderFormDhtmlTAXoopsCode(XoopsFormDhtmlTextArea $element) { - $textarea_id = $element->getName(); - $code = ''; - $code .= ''; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - - $myts = \MyTextSanitizer::getInstance(); - - $extensions = array_filter($myts->config['extensions']); - foreach (array_keys($extensions) as $key) { - $extension = $myts->loadExtension($key); - $result = $extension->encode($textarea_id); - $encode = $result[0] ?? ''; - $js = $result[1] ?? ''; - if (empty($encode)) { - continue; - } - $code .= $encode; - if (!empty($js)) { - $element->js .= $js; - } - } - $code .= ""; - $code .= ""; - - $xoopsPreload = XoopsPreload::getInstance(); - $xoopsPreload->triggerEvent('core.class.xoopsform.formdhtmltextarea.codeicon', [&$code]); - - return $code; + return (new \XoopsDhtmlToolbar())->renderCodeButtons($element); } /** * Render typography controls for editor (font, size, color) * + * Thin delegate to the shared {@see XoopsDhtmlToolbar}. Kept (rather than removed) because + * this method is `protected`, not part of {@see XoopsFormRendererInterface}, and a third-party + * subclass of this renderer may still call or override it. + * * @param XoopsFormDhtmlTextArea $element form element * * @return string rendered typography controls */ protected function renderFormDhtmlTATypography(XoopsFormDhtmlTextArea $element) { - $textarea_id = $element->getName(); - $hiddentext = $element->_hiddenText; - $fontStr = "'; - - $styleStr = ""; - $styleStr .= ""; - $styleStr .= ""; - $styleStr .= ""; - - $alignStr = ""; - $alignStr .= ""; - $alignStr .= ""; + $toolbar = new \XoopsDhtmlToolbar(); - $fontStr .= "
      \n{$styleStr} {$alignStr} \n"; - return $fontStr; + return $toolbar->renderTypography($element) . $toolbar->renderCheckLength($element); } /** diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php index fc03919ab..05fe250a5 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php @@ -20,6 +20,7 @@ defined('XOOPS_ROOT_PATH') || exit('Restricted access'); require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; +require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; /** * Tailwind CSS + DaisyUI form renderer @@ -409,9 +410,9 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) $savePositionJs = $this->buildJsCall('xoopsSavePosition', [$nameRaw]); - $ret = $this->renderFormDhtmlTAXoopsCode($element) . "
      \n"; - $ret .= $this->renderFormDhtmlTATypography($element); - $ret .= "
      \n"; + // toolbar: xoopscode buttons, typography, check-length — shared across all renderers + $toolbar = new \XoopsDhtmlToolbar(); + $ret = $toolbar->render($element) . "
      \n"; $ret .= '\n"; + . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -393,7 +396,7 @@ public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -464,7 +467,7 @@ public function renderFormText(XoopsFormText $element) return "getExtra() . ' />'; + . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } /** @@ -479,7 +482,7 @@ public function renderFormTextArea(XoopsFormTextArea $element) return "'; + . $element->getExtra() . '>' . $this->escapeElementValue($element->getValue()) . ''; } /** diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap4.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap4.php index 3de40208c..7f70fea9b 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap4.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap4.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; /** * Bootstrap4 style form renderer @@ -23,6 +24,8 @@ */ class XoopsFormRendererBootstrap4 implements XoopsFormRendererInterface, XoopsFormTabRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Counter giving each rendered tab tray a unique DOM id. * @@ -40,8 +43,8 @@ public function renderFormButton(XoopsFormButton $element) { return ''; } @@ -221,7 +224,7 @@ public function renderFormColorPicker(XoopsFormColorPicker $element) } return 'getExtra() . '>'; + . '" size="7" maxlength="7" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . '>'; } /** @@ -244,7 +247,7 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) . "');\" onclick=\"xoopsSavePosition('" . $element->getName() . "');\" onkeyup=\"xoopsSavePosition('" . $element->getName() . "');\" cols='" . $element->getCols() . "' rows='" . $element->getRows() . "'" . $element->getExtra() - . '>' . $element->getValue() . "\n"; + . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -396,7 +399,7 @@ public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -468,7 +471,7 @@ public function renderFormText(XoopsFormText $element) return "getExtra() . '>'; + . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . '>'; } /** @@ -483,7 +486,7 @@ public function renderFormTextArea(XoopsFormTextArea $element) return "'; + . $element->getExtra() . '>' . $this->escapeElementValue($element->getValue()) . ''; } /** diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap5.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap5.php index 8b82b973c..3975ece98 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap5.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap5.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; /** * Bootstrap5 style form renderer @@ -23,6 +24,8 @@ */ class XoopsFormRendererBootstrap5 implements XoopsFormRendererInterface, XoopsFormTabRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Counter giving each rendered tab tray a unique DOM id. * @@ -41,8 +44,8 @@ public function renderFormButton(XoopsFormButton $element) { return ''; } @@ -222,7 +225,7 @@ public function renderFormColorPicker(XoopsFormColorPicker $element) } return 'getExtra() . '>'; + . '" size="7" maxlength="7" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . '>'; } /** @@ -245,7 +248,7 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) . "');\" onclick=\"xoopsSavePosition('" . $element->getName() . "');\" onkeyup=\"xoopsSavePosition('" . $element->getName() . "');\" cols='" . $element->getCols() . "' rows='" . $element->getRows() . "'" . $element->getExtra() - . '>' . $element->getValue() . "\n"; + . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -397,7 +400,7 @@ public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -469,7 +472,7 @@ public function renderFormText(XoopsFormText $element) return "getExtra() . '>'; + . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . '>'; } /** @@ -484,7 +487,7 @@ public function renderFormTextArea(XoopsFormTextArea $element) return "'; + . $element->getExtra() . '>' . $this->escapeElementValue($element->getValue()) . ''; } /** diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererLegacy.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererLegacy.php index 041a4e469..8e3eb6f4c 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererLegacy.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererLegacy.php @@ -18,9 +18,12 @@ * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) */ require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; class XoopsFormRendererLegacy implements XoopsFormRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Render support for XoopsFormButton * @@ -31,8 +34,8 @@ class XoopsFormRendererLegacy implements XoopsFormRendererInterface public function renderFormButton(XoopsFormButton $element) { return "getExtra() . ' />'; + . "' id='" . $element->getName() . "' value='" . $this->escapeElementValue($element->getValue()) . "' title='" + . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } /** @@ -52,7 +55,7 @@ public function renderFormButtonTray(XoopsFormButtonTray $element) $ret .= ' ' . ' ' . 'getExtra() + . '" id="' . $element->getName() . '" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . ' />'; return $ret; @@ -143,7 +146,7 @@ public function renderFormColorPicker(XoopsFormColorPicker $element) } return "getExtra() + . $element->getMaxlength() . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } @@ -162,7 +165,7 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) $toolbar = new \XoopsDhtmlToolbar(); $ret .= $toolbar->render($element) . "
      \n"; // the textarea box - $ret .= "
      \n"; + $ret .= "
      \n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -297,7 +300,7 @@ public function renderFormLabel(XoopsFormLabel $element) public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -399,7 +402,7 @@ public function renderFormText(XoopsFormText $element) { return "getExtra() + . $element->getMaxlength() . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } @@ -414,7 +417,7 @@ public function renderFormTextArea(XoopsFormTextArea $element) { return "'; + . "'" . $element->getExtra() . '>' . $this->escapeElementValue($element->getValue()) . ''; } /** diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php index 05fe250a5..dacf279db 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php @@ -21,6 +21,7 @@ require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; /** * Tailwind CSS + DaisyUI form renderer @@ -44,6 +45,8 @@ */ class XoopsFormRendererTailwind implements XoopsFormRendererInterface, XoopsFormTabRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Counter giving each rendered tab tray a unique DOM id / radio group. * @@ -419,7 +422,7 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) . " onclick='" . $savePositionJs . "'" . " onkeyup='" . $savePositionJs . "'" . ' cols="' . (int) $element->getCols() . '" rows="' . (int) $element->getRows() . '"' - . $this->renderExtra($element) . '>' . $this->esc($element->getValue()) . "\n"; + . $this->renderExtra($element) . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -718,7 +721,7 @@ public function renderFormTextArea(XoopsFormTextArea $element) . ' rows="' . (int) $element->getRows() . '"' . ' cols="' . (int) $element->getCols() . '"' . $this->renderExtra($element) . '>' - . $this->esc($element->getValue()) . ''; + . $this->escapeElementValue($element->getValue()) . ''; } /** diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererValueEscapeTrait.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererValueEscapeTrait.php new file mode 100644 index 000000000..025b1901c --- /dev/null +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererValueEscapeTrait.php @@ -0,0 +1,47 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + * @since 2.7.3 + */ + +defined('XOOPS_ROOT_PATH') || exit('Restricted access'); + +trait XoopsFormRendererValueEscapeTrait +{ + /** + * Escape a form element value for HTML text/attribute context, idempotently. + * + * XOOPS callers historically hand elements an ALREADY-escaped value + * (getVar($k, 'e'|'E'), MyTextSanitizer::htmlSpecialChars()), and roughly 95% of + * core does. Escaping again here would double-escape them and rewrite stored text + * on the next save. Decoding first makes this a no-op for those callers while + * still neutralising the ones that pass raw user input -- which core itself does + * in at least two places (kernel/menusitems.php fetches with 'n', and + * include/comment_form.php re-displays raw $_POST on preview). + * + * Flags match kernel/object.php:478 so the round-trip is exact. + * + * @param mixed $value + * @return string + */ + protected function escapeElementValue($value): string + { + return htmlspecialchars( + htmlspecialchars_decode((string) $value, ENT_QUOTES | ENT_HTML5), + ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, + 'UTF-8' + ); + } +} From e4c2d2003c74138287df4b1dbc29fcc4b36ab0d8 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 3 Aug 2026 00:59:33 -0400 Subject: [PATCH 4/6] fix(imagemanager): use the core form renderer and enforce authorization Three copies of core renderers had accumulated under the TinyMCE image-manager plugins and were installed as the global renderer, so those screens did not receive core renderer changes. They were stale snapshots with no local modifications; removed, and the endpoints now xoops_load() the core class. Separately, the image category create, update and delete handlers validated the CSRF token but not the caller's permission - the admin check applied only to which controls were rendered. Guards added to all five handlers. --- .../XoopsFormRendererBootstrap4.php | 784 ------------------ .../xoopsimagemanager/xoopsimagemanager.php | 22 +- .../XoopsFormRendererBootstrap4.php | 784 ------------------ .../XoopsFormRendererBootstrap5.php | 771 ----------------- .../xoopsimagemanager/xoopsimagemanager.php | 22 +- 5 files changed, 42 insertions(+), 2341 deletions(-) delete mode 100644 htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php delete mode 100644 htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php delete mode 100644 htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap5.php diff --git a/htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php b/htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php deleted file mode 100644 index 56e7b877a..000000000 --- a/htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php +++ /dev/null @@ -1,784 +0,0 @@ - - * @copyright 2000-2026 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (https://www.gnu.org/licenses/gpl-2.0.html) - */ -class XoopsFormRendererBootstrap4 implements XoopsFormRendererInterface -{ - /** - * Render support for XoopsFormButton - * - * @param XoopsFormButton $element form element - * - * @return string rendered form element - */ - public function renderFormButton(XoopsFormButton $element) - { - return ''; - } - - - /** - * Render support for XoopsFormButtonTray - * - * @param XoopsFormButtonTray $element form element - * - * @return string rendered form element - */ - public function renderFormButtonTray(XoopsFormButtonTray $element) - { - $ret = ''; - if ($element->_showDelete) { - $ret .= ''; - } - $ret .= '' - . '' - . ''; - - return $ret; - } - - /** - * Render support for XoopsFormCheckBox - * - * @param XoopsFormCheckBox $element form element - * - * @return string rendered form element - */ - public function renderFormCheckBox(XoopsFormCheckBox $element) - { - $elementName = $element->getName(); - $elementId = $elementName; - $elementOptions = $element->getOptions(); - if (count($elementOptions) > 1 && substr($elementName, -2, 2) !== '[]') { - $elementName .= '[]'; - $element->setName($elementName); - } - - switch ((int) ($element->columns)) { - case 0: - return $this->renderCheckedInline($element, 'checkbox', $elementId, $elementName); - case 1: - return $this->renderCheckedOneColumn($element, 'checkbox', $elementId, $elementName); - default: - return $this->renderCheckedColumnar($element, 'checkbox', $elementId, $elementName); - } - } - - /** - * Render a inline checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio; - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedInline($element, $type, $elementId, $elementName) - { - $class = $type . '-inline'; - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - - $ret .= '
      '; - $ret .= "getExtra() . '>'; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - - /** - * Render a single column checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio; - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedOneColumn($element, $type, $elementId, $elementName) - { - $class = $type; - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - $ret .= '
      '; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - - /** - * Render a multicolumn checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio; - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedColumnar($element, $type, $elementId, $elementName) - { - $class = $type; - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - - $ret .= '
      '; - $ret .= "getExtra() . '>'; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - /** - * Render support for XoopsFormColorPicker - * - * @param XoopsFormColorPicker $element form element - * - * @return string rendered form element - */ - public function renderFormColorPicker(XoopsFormColorPicker $element) - { - if (isset($GLOBALS['xoTheme'])) { - $GLOBALS['xoTheme']->addScript('include/spectrum.js'); - $GLOBALS['xoTheme']->addStylesheet('include/spectrum.css'); - } else { - echo ''; - echo ''; - } - return 'getExtra() . '>'; - } - - /** - * Render support for XoopsFormDhtmlTextArea - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered form element - */ - public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) - { - static $js_loaded; - - xoops_loadLanguage('formdhtmltextarea'); - $ret = ''; - // actions - $ret .= $this->renderFormDhtmlTAXoopsCode($element) . "
      \n"; - // fonts - $ret .= $this->renderFormDhtmlTATypography($element); - // length checker - - $ret .= "
      \n"; - // the textarea box - $ret .= "\n"; - - if (empty($element->skipPreview)) { - if (empty($GLOBALS['xoTheme'])) { - $element->js .= implode('', file(XOOPS_ROOT_PATH . '/class/textsanitizer/image/image.js')); - } else { - $GLOBALS['xoTheme']->addScript( - '/class/textsanitizer/image/image.js', - ['type' => 'text/javascript'], - ); - } - $button = ""; - - $ret .= '
      ' . "
      " - . '
      ' . ' ' . $button . '' - . "
      " . _XOOPS_FORM_PREVIEW_CONTENT - . '
      ' . '
      ' . '
      '; - } - // Load javascript - if (empty($js_loaded)) { - $javascript = ($element->js ? '' : '') - . ''; - $ret = $javascript . $ret; - $js_loaded = true; - } - - return $ret; - } - - /** - * Render xoopscode buttons for editor, include calling text sanitizer extensions - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered buttons for xoopscode assistance - */ - protected function renderFormDhtmlTAXoopsCode(XoopsFormDhtmlTextArea $element) - { - $textarea_id = $element->getName(); - $code = ''; - $code .= "
      "; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - - $myts = \MyTextSanitizer::getInstance(); - - $extensions = array_filter($myts->config['extensions']); - foreach (array_keys($extensions) as $key) { - $extension = $myts->loadExtension($key); - @[$encode, $js] = $extension->encode($textarea_id); - if (empty($encode)) { - continue; - } - // TODO - MyTextSanitizer button rendering should go through XoopsFormRenderer - $encode = str_replace('btn-default', 'btn-secondary', $encode); - - $code .= $encode; - if (!empty($js)) { - $element->js .= $js; - } - } - $code .= ""; - $code .= ""; - $code .= "
      "; - - $xoopsPreload = XoopsPreload::getInstance(); - $xoopsPreload->triggerEvent('core.class.xoopsform.formdhtmltextarea.codeicon', [&$code]); - - return $code; - } - - /** - * Render typography controls for editor (font, size, color) - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered typography controls - */ - protected function renderFormDhtmlTATypography(XoopsFormDhtmlTextArea $element) - { - $textarea_id = $element->getName(); - $hiddentext = $element->_hiddenText; - - $fontarray = !empty($GLOBALS['formtextdhtml_fonts']) ? $GLOBALS['formtextdhtml_fonts'] : [ - 'Arial', - 'Courier', - 'Georgia', - 'Helvetica', - 'Impact', - 'Verdana', - 'Haettenschweiler', - ]; - - $colorArray = [ - 'Black' => '000000', - 'Blue' => '38AAFF', - 'Brown' => '987857', - 'Green' => '79D271', - 'Grey' => '888888', - 'Orange' => 'FFA700', - 'Paper' => 'E0E0E0', - 'Purple' => '363E98', - 'Red' => 'FF211E', - 'White' => 'FEFEFE', - 'Yellow' => 'FFD628', - ]; - - $fontStr = '
      '; - - //$styleStr = "
      "; - $styleStr = "
      "; - $styleStr .= ""; - $styleStr .= ""; - $styleStr .= "'; - $styleStr .= "'; - $styleStr .= "
      "; - - $alignStr = "
      "; - $alignStr .= ""; - $alignStr .= ""; - $alignStr .= ""; - $alignStr .= "
      "; - - $fontStr .= " {$styleStr} {$alignStr} \n"; - - $fontStr .= ""; - $fontStr .= "
      "; - - return $fontStr; - } - - /** - * Render support for XoopsFormElementTray - * - * @param XoopsFormElementTray $element form element - * - * @return string rendered form element - */ - public function renderFormElementTray(XoopsFormElementTray $element) - { - $count = 0; - $ret = ''; - foreach ($element->getElements() as $ele) { - if ($count > 0) { - $ret .= $element->getDelimeter(); - } - if ($ele->getCaption() != '') { - $ret .= $ele->getCaption() . ' '; - } - $ret .= $ele->render() . NWLINE; - if (!$ele->isHidden()) { - ++$count; - } - } - /* - if (substr_count($ret, '
      ') > 0) { - $ret = str_replace('
      ', '', $ret); - $ret = str_replace('
      ', '', $ret); - } - if (substr_count($ret, '
      ') > 0) { - $ret = str_replace('
      ', '', $ret); - } - */ - $ret .= ''; - return $ret; - } - - /** - * Render support for XoopsFormFile - * - * @param XoopsFormFile $element form element - * - * @return string rendered form element - */ - public function renderFormFile(XoopsFormFile $element) - { - return '' - . 'getExtra() . '>' - . ''; - } - - /** - * Render support for XoopsFormLabel - * - * @param XoopsFormLabel $element form element - * - * @return string rendered form element - */ - public function renderFormLabel(XoopsFormLabel $element) - { - return '
      ' . $element->getValue() . '
      '; - } - - /** - * Render support for XoopsFormPassword - * - * @param XoopsFormPassword $element form element - * - * @return string rendered form element - */ - public function renderFormPassword(XoopsFormPassword $element) - { - return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; - } - - /** - * Render support for XoopsFormRadio - * - * @param XoopsFormRadio $element form element - * - * @return string rendered form element - */ - public function renderFormRadio(XoopsFormRadio $element) - { - - $elementName = $element->getName(); - $elementId = $elementName; - - switch ((int) ($element->columns)) { - case 0: - return $this->renderCheckedInline($element, 'radio', $elementId, $elementName); - case 1: - return $this->renderCheckedOneColumn($element, 'radio', $elementId, $elementName); - default: - return $this->renderCheckedColumnar($element, 'radio', $elementId, $elementName); - } - } - - /** - * Render support for XoopsFormSelect - * - * @param XoopsFormSelect $element form element - * - * @return string rendered form element - */ - public function renderFormSelect(XoopsFormSelect $element) - { - $ele_name = $element->getName(); - $ele_title = $element->getTitle(); - $ele_value = $element->getValue(); - $ele_options = $element->getOptions(); - $ret = ''; - - return $ret; - } - /** - * Render support for XoopsFormText - * - * @param XoopsFormText $element form element - * - * @return string rendered form element - */ - public function renderFormText(XoopsFormText $element) - { - return "getExtra() . '>'; - } - - /** - * Render support for XoopsFormTextArea - * - * @param XoopsFormTextArea $element form element - * - * @return string rendered form element - */ - public function renderFormTextArea(XoopsFormTextArea $element) - { - return "'; - } - - /** - * Render support for XoopsFormTextDateSelect - * - * @param XoopsFormTextDateSelect $element form element - * - * @return string rendered form element - */ - public function renderFormTextDateSelect(XoopsFormTextDateSelect $element) - { - static $included = false; - if (file_exists(XOOPS_ROOT_PATH . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/calendar.php')) { - include_once XOOPS_ROOT_PATH . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/calendar.php'; - } else { - include_once XOOPS_ROOT_PATH . '/language/english/calendar.php'; - } - - $ele_name = $element->getName(); - $ele_value = $element->getValue(false); - if (is_string($ele_value)) { - $display_value = $ele_value; - $ele_value = time(); - } elseif ($ele_value === 0) { - $display_value = ''; - $ele_value = time(); - } else { - $display_value = date(_SHORTDATESTRING, $ele_value); - } - - $jstime = formatTimestamp($ele_value, 'm/d/Y'); - if (isset($GLOBALS['xoTheme']) && is_object($GLOBALS['xoTheme'])) { - $GLOBALS['xoTheme']->addScript('include/calendar.js'); - $GLOBALS['xoTheme']->addStylesheet('include/calendar-blue.css'); - if (!$included) { - $included = true; - $GLOBALS['xoTheme']->addScript('', '', ' - var calendar = null; - - function selected(cal, date) - { - cal.sel.value = date; - } - - function closeHandler(cal) - { - cal.hide(); - Calendar.removeEvent(document, "mousedown", checkCalendar); - } - - function checkCalendar(ev) - { - var el = Calendar.is_ie ? Calendar.getElement(ev) : Calendar.getTargetElement(ev); - for (; el != null; el = el.parentNode) - if (el == calendar.element || el.tagName == "A") break; - if (el == null) { - calendar.callCloseHandler(); Calendar.stopEvent(ev); - } - } - function showCalendar(id) - { - var el = xoopsGetElementById(id); - if (calendar != null) { - calendar.hide(); - } else { - var cal = new Calendar(true, "' . $jstime . '", selected, closeHandler); - calendar = cal; - cal.setRange(1900, 2100); - calendar.create(); - } - calendar.sel = el; - calendar.parseDate(el.value); - calendar.showAtElement(el); - Calendar.addEvent(document, "mousedown", checkCalendar); - - return false; - } - - Calendar._DN = new Array - ("' . _CAL_SUNDAY . '", - "' . _CAL_MONDAY . '", - "' . _CAL_TUESDAY . '", - "' . _CAL_WEDNESDAY . '", - "' . _CAL_THURSDAY . '", - "' . _CAL_FRIDAY . '", - "' . _CAL_SATURDAY . '", - "' . _CAL_SUNDAY . '"); - Calendar._MN = new Array - ("' . _CAL_JANUARY . '", - "' . _CAL_FEBRUARY . '", - "' . _CAL_MARCH . '", - "' . _CAL_APRIL . '", - "' . _CAL_MAY . '", - "' . _CAL_JUNE . '", - "' . _CAL_JULY . '", - "' . _CAL_AUGUST . '", - "' . _CAL_SEPTEMBER . '", - "' . _CAL_OCTOBER . '", - "' . _CAL_NOVEMBER . '", - "' . _CAL_DECEMBER . '"); - - Calendar._TT = {}; - Calendar._TT["TOGGLE"] = "' . _CAL_TGL1STD . '"; - Calendar._TT["PREV_YEAR"] = "' . _CAL_PREVYR . '"; - Calendar._TT["PREV_MONTH"] = "' . _CAL_PREVMNTH . '"; - Calendar._TT["GO_TODAY"] = "' . _CAL_GOTODAY . '"; - Calendar._TT["NEXT_MONTH"] = "' . _CAL_NXTMNTH . '"; - Calendar._TT["NEXT_YEAR"] = "' . _CAL_NEXTYR . '"; - Calendar._TT["SEL_DATE"] = "' . _CAL_SELDATE . '"; - Calendar._TT["DRAG_TO_MOVE"] = "' . _CAL_DRAGMOVE . '"; - Calendar._TT["PART_TODAY"] = "(' . _CAL_TODAY . ')"; - Calendar._TT["MON_FIRST"] = "' . _CAL_DISPM1ST . '"; - Calendar._TT["SUN_FIRST"] = "' . _CAL_DISPS1ST . '"; - Calendar._TT["CLOSE"] = "' . _CLOSE . '"; - Calendar._TT["TODAY"] = "' . _CAL_TODAY . '"; - - // date formats - Calendar._TT["DEF_DATE_FORMAT"] = "' . _SHORTDATESTRING . '"; - Calendar._TT["TT_DATE_FORMAT"] = "' . _SHORTDATESTRING . '"; - - Calendar._TT["WK"] = ""; - '); - } - } - return '
      ' - . 'getExtra() . '>' - . '
      ' - . '
      ' - . '
      '; - } - - /** - * Render support for XoopsThemeForm - * - * @param XoopsThemeForm $form form to render - * - * @return string rendered form - */ - public function renderThemeForm(XoopsThemeForm $form) - { - $ele_name = $form->getName(); - - $ret = '
      '; - $ret .= '
      getExtra() . '>' - . '

      ' . $form->getTitle() . '

      '; - $hidden = ''; - - foreach ($form->getElements() as $element) { - if (!is_object($element)) { // see $form->addBreak() - $ret .= $element; - continue; - } - if ($element->isHidden()) { - $hidden .= $element->render(); - continue; - } - - $ret .= '
      '; - if (($caption = $element->getCaption()) != '') { - $ret .= ''; - } else { - $ret .= '
      '; - } - $ret .= '
      '; - $ret .= $element->render(); - if (($desc = $element->getDescription()) != '') { - $ret .= '

      ' . $desc . '

      '; - } - $ret .= '
      '; - $ret .= '
      '; - } - $ret .= $hidden; - $ret .= '
      '; - $ret .= $form->renderValidationJS(true); - - return $ret; - } - - /** - * Support for themed addBreak - * - * @param XoopsThemeForm $form - * @param string $extra pre-rendered content for break row - * @param string $class class for row - * - * @return void - */ - public function addThemeFormBreak(XoopsThemeForm $form, $extra, $class) - { - $class = ($class != '') ? preg_replace('/[^A-Za-z0-9\s\s_-]/i', '', $class) : ''; - $form->addElement('
      ' . $extra . '
      '); - } -} diff --git a/htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php b/htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php index 2150c3153..0b933b691 100644 --- a/htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php +++ b/htdocs/class/xoopseditor/tinymce5/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php @@ -67,7 +67,7 @@ //xoops_load("xoopsmodule"); include_once XOOPS_ROOT_PATH . '/include/cp_functions.php'; include_once XOOPS_ROOT_PATH . '/modules/system/constants.php'; -include_once __DIR__ . '/XoopsFormRendererBootstrap4.php'; +xoops_load('xoopsformrendererbootstrap4'); XoopsFormRenderer::getInstance()->set(new XoopsFormRendererBootstrap4()); @@ -165,6 +165,10 @@ // Add new category - start if ($op === 'addcat' && \Xmf\Request::hasVar('op', 'POST')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if (!$GLOBALS['xoopsSecurity']->check()) { redirect_header($current_file . '?target=' . $target, 3, implode('
      ', $GLOBALS['xoopsSecurity']->getErrors())); } @@ -220,6 +224,10 @@ // Update category - start if ($op === 'updatecat' && \Xmf\Request::hasVar('op', 'POST')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if (!$GLOBALS['xoopsSecurity']->check() || $imgcat_id <= 0) { redirect_header($current_file . '?target=' . $target, 3, implode('
      ', $GLOBALS['xoopsSecurity']->getErrors())); } @@ -281,6 +289,10 @@ // Confirm delete category - start if ($op === 'delcat' && \Xmf\Request::hasVar('op', 'GET')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + xoops_header(); echo ""; xoops_confirm(['op' => 'delcatok', 'imgcat_id' => $imgcat_id, 'target' => $target], $current_file, _MD_RUDELIMGCAT); @@ -291,6 +303,10 @@ // Delete category - start if ($op === 'delcatok' && \Xmf\Request::hasVar('op', 'POST')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if (!$GLOBALS['xoopsSecurity']->check()) { redirect_header($current_file . '?target=' . $target, 3, implode('
      ', $GLOBALS['xoopsSecurity']->getErrors())); } @@ -500,6 +516,10 @@ } if ($op === 'editcat') { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if ($imgcat_id <= 0) { redirect_header($current_file . '?target=' . $target, 1); } diff --git a/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php b/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php deleted file mode 100644 index 56e7b877a..000000000 --- a/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap4.php +++ /dev/null @@ -1,784 +0,0 @@ - - * @copyright 2000-2026 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (https://www.gnu.org/licenses/gpl-2.0.html) - */ -class XoopsFormRendererBootstrap4 implements XoopsFormRendererInterface -{ - /** - * Render support for XoopsFormButton - * - * @param XoopsFormButton $element form element - * - * @return string rendered form element - */ - public function renderFormButton(XoopsFormButton $element) - { - return ''; - } - - - /** - * Render support for XoopsFormButtonTray - * - * @param XoopsFormButtonTray $element form element - * - * @return string rendered form element - */ - public function renderFormButtonTray(XoopsFormButtonTray $element) - { - $ret = ''; - if ($element->_showDelete) { - $ret .= ''; - } - $ret .= '' - . '' - . ''; - - return $ret; - } - - /** - * Render support for XoopsFormCheckBox - * - * @param XoopsFormCheckBox $element form element - * - * @return string rendered form element - */ - public function renderFormCheckBox(XoopsFormCheckBox $element) - { - $elementName = $element->getName(); - $elementId = $elementName; - $elementOptions = $element->getOptions(); - if (count($elementOptions) > 1 && substr($elementName, -2, 2) !== '[]') { - $elementName .= '[]'; - $element->setName($elementName); - } - - switch ((int) ($element->columns)) { - case 0: - return $this->renderCheckedInline($element, 'checkbox', $elementId, $elementName); - case 1: - return $this->renderCheckedOneColumn($element, 'checkbox', $elementId, $elementName); - default: - return $this->renderCheckedColumnar($element, 'checkbox', $elementId, $elementName); - } - } - - /** - * Render a inline checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio; - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedInline($element, $type, $elementId, $elementName) - { - $class = $type . '-inline'; - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - - $ret .= '
      '; - $ret .= "getExtra() . '>'; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - - /** - * Render a single column checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio; - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedOneColumn($element, $type, $elementId, $elementName) - { - $class = $type; - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - $ret .= '
      '; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - - /** - * Render a multicolumn checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio; - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedColumnar($element, $type, $elementId, $elementName) - { - $class = $type; - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - - $ret .= '
      '; - $ret .= "getExtra() . '>'; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - /** - * Render support for XoopsFormColorPicker - * - * @param XoopsFormColorPicker $element form element - * - * @return string rendered form element - */ - public function renderFormColorPicker(XoopsFormColorPicker $element) - { - if (isset($GLOBALS['xoTheme'])) { - $GLOBALS['xoTheme']->addScript('include/spectrum.js'); - $GLOBALS['xoTheme']->addStylesheet('include/spectrum.css'); - } else { - echo ''; - echo ''; - } - return 'getExtra() . '>'; - } - - /** - * Render support for XoopsFormDhtmlTextArea - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered form element - */ - public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) - { - static $js_loaded; - - xoops_loadLanguage('formdhtmltextarea'); - $ret = ''; - // actions - $ret .= $this->renderFormDhtmlTAXoopsCode($element) . "
      \n"; - // fonts - $ret .= $this->renderFormDhtmlTATypography($element); - // length checker - - $ret .= "
      \n"; - // the textarea box - $ret .= "\n"; - - if (empty($element->skipPreview)) { - if (empty($GLOBALS['xoTheme'])) { - $element->js .= implode('', file(XOOPS_ROOT_PATH . '/class/textsanitizer/image/image.js')); - } else { - $GLOBALS['xoTheme']->addScript( - '/class/textsanitizer/image/image.js', - ['type' => 'text/javascript'], - ); - } - $button = ""; - - $ret .= '
      ' . "
      " - . '
      ' . ' ' . $button . '' - . "
      " . _XOOPS_FORM_PREVIEW_CONTENT - . '
      ' . '
      ' . '
      '; - } - // Load javascript - if (empty($js_loaded)) { - $javascript = ($element->js ? '' : '') - . ''; - $ret = $javascript . $ret; - $js_loaded = true; - } - - return $ret; - } - - /** - * Render xoopscode buttons for editor, include calling text sanitizer extensions - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered buttons for xoopscode assistance - */ - protected function renderFormDhtmlTAXoopsCode(XoopsFormDhtmlTextArea $element) - { - $textarea_id = $element->getName(); - $code = ''; - $code .= "
      "; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - - $myts = \MyTextSanitizer::getInstance(); - - $extensions = array_filter($myts->config['extensions']); - foreach (array_keys($extensions) as $key) { - $extension = $myts->loadExtension($key); - @[$encode, $js] = $extension->encode($textarea_id); - if (empty($encode)) { - continue; - } - // TODO - MyTextSanitizer button rendering should go through XoopsFormRenderer - $encode = str_replace('btn-default', 'btn-secondary', $encode); - - $code .= $encode; - if (!empty($js)) { - $element->js .= $js; - } - } - $code .= ""; - $code .= ""; - $code .= "
      "; - - $xoopsPreload = XoopsPreload::getInstance(); - $xoopsPreload->triggerEvent('core.class.xoopsform.formdhtmltextarea.codeicon', [&$code]); - - return $code; - } - - /** - * Render typography controls for editor (font, size, color) - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered typography controls - */ - protected function renderFormDhtmlTATypography(XoopsFormDhtmlTextArea $element) - { - $textarea_id = $element->getName(); - $hiddentext = $element->_hiddenText; - - $fontarray = !empty($GLOBALS['formtextdhtml_fonts']) ? $GLOBALS['formtextdhtml_fonts'] : [ - 'Arial', - 'Courier', - 'Georgia', - 'Helvetica', - 'Impact', - 'Verdana', - 'Haettenschweiler', - ]; - - $colorArray = [ - 'Black' => '000000', - 'Blue' => '38AAFF', - 'Brown' => '987857', - 'Green' => '79D271', - 'Grey' => '888888', - 'Orange' => 'FFA700', - 'Paper' => 'E0E0E0', - 'Purple' => '363E98', - 'Red' => 'FF211E', - 'White' => 'FEFEFE', - 'Yellow' => 'FFD628', - ]; - - $fontStr = '
      '; - - //$styleStr = "
      "; - $styleStr = "
      "; - $styleStr .= ""; - $styleStr .= ""; - $styleStr .= "'; - $styleStr .= "'; - $styleStr .= "
      "; - - $alignStr = "
      "; - $alignStr .= ""; - $alignStr .= ""; - $alignStr .= ""; - $alignStr .= "
      "; - - $fontStr .= " {$styleStr} {$alignStr} \n"; - - $fontStr .= ""; - $fontStr .= "
      "; - - return $fontStr; - } - - /** - * Render support for XoopsFormElementTray - * - * @param XoopsFormElementTray $element form element - * - * @return string rendered form element - */ - public function renderFormElementTray(XoopsFormElementTray $element) - { - $count = 0; - $ret = ''; - foreach ($element->getElements() as $ele) { - if ($count > 0) { - $ret .= $element->getDelimeter(); - } - if ($ele->getCaption() != '') { - $ret .= $ele->getCaption() . ' '; - } - $ret .= $ele->render() . NWLINE; - if (!$ele->isHidden()) { - ++$count; - } - } - /* - if (substr_count($ret, '
      ') > 0) { - $ret = str_replace('
      ', '', $ret); - $ret = str_replace('
      ', '', $ret); - } - if (substr_count($ret, '
      ') > 0) { - $ret = str_replace('
      ', '', $ret); - } - */ - $ret .= ''; - return $ret; - } - - /** - * Render support for XoopsFormFile - * - * @param XoopsFormFile $element form element - * - * @return string rendered form element - */ - public function renderFormFile(XoopsFormFile $element) - { - return '' - . 'getExtra() . '>' - . ''; - } - - /** - * Render support for XoopsFormLabel - * - * @param XoopsFormLabel $element form element - * - * @return string rendered form element - */ - public function renderFormLabel(XoopsFormLabel $element) - { - return '
      ' . $element->getValue() . '
      '; - } - - /** - * Render support for XoopsFormPassword - * - * @param XoopsFormPassword $element form element - * - * @return string rendered form element - */ - public function renderFormPassword(XoopsFormPassword $element) - { - return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; - } - - /** - * Render support for XoopsFormRadio - * - * @param XoopsFormRadio $element form element - * - * @return string rendered form element - */ - public function renderFormRadio(XoopsFormRadio $element) - { - - $elementName = $element->getName(); - $elementId = $elementName; - - switch ((int) ($element->columns)) { - case 0: - return $this->renderCheckedInline($element, 'radio', $elementId, $elementName); - case 1: - return $this->renderCheckedOneColumn($element, 'radio', $elementId, $elementName); - default: - return $this->renderCheckedColumnar($element, 'radio', $elementId, $elementName); - } - } - - /** - * Render support for XoopsFormSelect - * - * @param XoopsFormSelect $element form element - * - * @return string rendered form element - */ - public function renderFormSelect(XoopsFormSelect $element) - { - $ele_name = $element->getName(); - $ele_title = $element->getTitle(); - $ele_value = $element->getValue(); - $ele_options = $element->getOptions(); - $ret = ''; - - return $ret; - } - /** - * Render support for XoopsFormText - * - * @param XoopsFormText $element form element - * - * @return string rendered form element - */ - public function renderFormText(XoopsFormText $element) - { - return "getExtra() . '>'; - } - - /** - * Render support for XoopsFormTextArea - * - * @param XoopsFormTextArea $element form element - * - * @return string rendered form element - */ - public function renderFormTextArea(XoopsFormTextArea $element) - { - return "'; - } - - /** - * Render support for XoopsFormTextDateSelect - * - * @param XoopsFormTextDateSelect $element form element - * - * @return string rendered form element - */ - public function renderFormTextDateSelect(XoopsFormTextDateSelect $element) - { - static $included = false; - if (file_exists(XOOPS_ROOT_PATH . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/calendar.php')) { - include_once XOOPS_ROOT_PATH . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/calendar.php'; - } else { - include_once XOOPS_ROOT_PATH . '/language/english/calendar.php'; - } - - $ele_name = $element->getName(); - $ele_value = $element->getValue(false); - if (is_string($ele_value)) { - $display_value = $ele_value; - $ele_value = time(); - } elseif ($ele_value === 0) { - $display_value = ''; - $ele_value = time(); - } else { - $display_value = date(_SHORTDATESTRING, $ele_value); - } - - $jstime = formatTimestamp($ele_value, 'm/d/Y'); - if (isset($GLOBALS['xoTheme']) && is_object($GLOBALS['xoTheme'])) { - $GLOBALS['xoTheme']->addScript('include/calendar.js'); - $GLOBALS['xoTheme']->addStylesheet('include/calendar-blue.css'); - if (!$included) { - $included = true; - $GLOBALS['xoTheme']->addScript('', '', ' - var calendar = null; - - function selected(cal, date) - { - cal.sel.value = date; - } - - function closeHandler(cal) - { - cal.hide(); - Calendar.removeEvent(document, "mousedown", checkCalendar); - } - - function checkCalendar(ev) - { - var el = Calendar.is_ie ? Calendar.getElement(ev) : Calendar.getTargetElement(ev); - for (; el != null; el = el.parentNode) - if (el == calendar.element || el.tagName == "A") break; - if (el == null) { - calendar.callCloseHandler(); Calendar.stopEvent(ev); - } - } - function showCalendar(id) - { - var el = xoopsGetElementById(id); - if (calendar != null) { - calendar.hide(); - } else { - var cal = new Calendar(true, "' . $jstime . '", selected, closeHandler); - calendar = cal; - cal.setRange(1900, 2100); - calendar.create(); - } - calendar.sel = el; - calendar.parseDate(el.value); - calendar.showAtElement(el); - Calendar.addEvent(document, "mousedown", checkCalendar); - - return false; - } - - Calendar._DN = new Array - ("' . _CAL_SUNDAY . '", - "' . _CAL_MONDAY . '", - "' . _CAL_TUESDAY . '", - "' . _CAL_WEDNESDAY . '", - "' . _CAL_THURSDAY . '", - "' . _CAL_FRIDAY . '", - "' . _CAL_SATURDAY . '", - "' . _CAL_SUNDAY . '"); - Calendar._MN = new Array - ("' . _CAL_JANUARY . '", - "' . _CAL_FEBRUARY . '", - "' . _CAL_MARCH . '", - "' . _CAL_APRIL . '", - "' . _CAL_MAY . '", - "' . _CAL_JUNE . '", - "' . _CAL_JULY . '", - "' . _CAL_AUGUST . '", - "' . _CAL_SEPTEMBER . '", - "' . _CAL_OCTOBER . '", - "' . _CAL_NOVEMBER . '", - "' . _CAL_DECEMBER . '"); - - Calendar._TT = {}; - Calendar._TT["TOGGLE"] = "' . _CAL_TGL1STD . '"; - Calendar._TT["PREV_YEAR"] = "' . _CAL_PREVYR . '"; - Calendar._TT["PREV_MONTH"] = "' . _CAL_PREVMNTH . '"; - Calendar._TT["GO_TODAY"] = "' . _CAL_GOTODAY . '"; - Calendar._TT["NEXT_MONTH"] = "' . _CAL_NXTMNTH . '"; - Calendar._TT["NEXT_YEAR"] = "' . _CAL_NEXTYR . '"; - Calendar._TT["SEL_DATE"] = "' . _CAL_SELDATE . '"; - Calendar._TT["DRAG_TO_MOVE"] = "' . _CAL_DRAGMOVE . '"; - Calendar._TT["PART_TODAY"] = "(' . _CAL_TODAY . ')"; - Calendar._TT["MON_FIRST"] = "' . _CAL_DISPM1ST . '"; - Calendar._TT["SUN_FIRST"] = "' . _CAL_DISPS1ST . '"; - Calendar._TT["CLOSE"] = "' . _CLOSE . '"; - Calendar._TT["TODAY"] = "' . _CAL_TODAY . '"; - - // date formats - Calendar._TT["DEF_DATE_FORMAT"] = "' . _SHORTDATESTRING . '"; - Calendar._TT["TT_DATE_FORMAT"] = "' . _SHORTDATESTRING . '"; - - Calendar._TT["WK"] = ""; - '); - } - } - return '
      ' - . 'getExtra() . '>' - . '
      ' - . '
      ' - . '
      '; - } - - /** - * Render support for XoopsThemeForm - * - * @param XoopsThemeForm $form form to render - * - * @return string rendered form - */ - public function renderThemeForm(XoopsThemeForm $form) - { - $ele_name = $form->getName(); - - $ret = '
      '; - $ret .= '
      getExtra() . '>' - . '

      ' . $form->getTitle() . '

      '; - $hidden = ''; - - foreach ($form->getElements() as $element) { - if (!is_object($element)) { // see $form->addBreak() - $ret .= $element; - continue; - } - if ($element->isHidden()) { - $hidden .= $element->render(); - continue; - } - - $ret .= '
      '; - if (($caption = $element->getCaption()) != '') { - $ret .= ''; - } else { - $ret .= '
      '; - } - $ret .= '
      '; - $ret .= $element->render(); - if (($desc = $element->getDescription()) != '') { - $ret .= '

      ' . $desc . '

      '; - } - $ret .= '
      '; - $ret .= '
      '; - } - $ret .= $hidden; - $ret .= '
      '; - $ret .= $form->renderValidationJS(true); - - return $ret; - } - - /** - * Support for themed addBreak - * - * @param XoopsThemeForm $form - * @param string $extra pre-rendered content for break row - * @param string $class class for row - * - * @return void - */ - public function addThemeFormBreak(XoopsThemeForm $form, $extra, $class) - { - $class = ($class != '') ? preg_replace('/[^A-Za-z0-9\s\s_-]/i', '', $class) : ''; - $form->addElement('
      ' . $extra . '
      '); - } -} diff --git a/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap5.php b/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap5.php deleted file mode 100644 index c0a5f34a6..000000000 --- a/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/XoopsFormRendererBootstrap5.php +++ /dev/null @@ -1,771 +0,0 @@ -, updated for BS5 - * @copyright 2000-2026 XOOPS Project[](https://xoops.org) - * @license GNU GPL 2 or later[](https://www.gnu.org/licenses/gpl-2.0.html) - */ -class XoopsFormRendererBootstrap5 implements XoopsFormRendererInterface -{ - /** - * Render support for XoopsFormButton - * - * @param XoopsFormButton $element form element - * - * @return string rendered form element - */ - public function renderFormButton(XoopsFormButton $element) - { - return ''; - } - - /** - * Render support for XoopsFormButtonTray - * - * @param XoopsFormButtonTray $element form element - * - * @return string rendered form element - */ - public function renderFormButtonTray(XoopsFormButtonTray $element) - { - $ret = ''; - if ($element->_showDelete) { - $ret .= ''; - } - $ret .= '' - . '' - . ''; - - return $ret; - } - - /** - * Render support for XoopsFormCheckBox - * - * @param XoopsFormCheckBox $element form element - * - * @return string rendered form element - */ - public function renderFormCheckBox(XoopsFormCheckBox $element) - { - $elementName = $element->getName(); - $elementId = $elementName; - $elementOptions = $element->getOptions(); - if (count($elementOptions) > 1 && substr($elementName, -2, 2) !== '[]') { - $elementName .= '[]'; - $element->setName($elementName); - } - - switch ((int) ($element->columns)) { - case 0: - return $this->renderCheckedInline($element, 'checkbox', $elementId, $elementName); - case 1: - return $this->renderCheckedOneColumn($element, 'checkbox', $elementId, $elementName); - default: - return $this->renderCheckedColumnar($element, 'checkbox', $elementId, $elementName); - } - } - - /** - * Render a inline checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio' - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedInline($element, $type, $elementId, $elementName) - { - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - - $ret .= '
      '; - $ret .= "getExtra() . '>'; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - - /** - * Render a single column checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio' - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedOneColumn($element, $type, $elementId, $elementName) - { - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - $ret .= '
      '; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - - /** - * Render a multicolumn checkbox or radio element - * - * @param XoopsFormCheckBox|XoopsFormRadio $element element being rendered - * @param string $type 'checkbox' or 'radio' - * @param string $elementId input 'id' attribute of element - * @param string $elementName input 'name' attribute of element - * @return string - */ - protected function renderCheckedColumnar($element, $type, $elementId, $elementName) - { - $ret = ''; - - $idSuffix = 0; - $elementValue = $element->getValue(); - $elementOptions = $element->getOptions(); - foreach ($elementOptions as $value => $name) { - ++$idSuffix; - - $ret .= '
      '; - $ret .= "getExtra() . '>'; - $ret .= ''; - $ret .= '
      '; - } - - return $ret; - } - - /** - * Render support for XoopsFormColorPicker - * - * @param XoopsFormColorPicker $element form element - * - * @return string rendered form element - */ - public function renderFormColorPicker(XoopsFormColorPicker $element) - { - if (isset($GLOBALS['xoTheme'])) { - $GLOBALS['xoTheme']->addScript('include/spectrum.js'); - $GLOBALS['xoTheme']->addStylesheet('include/spectrum.css'); - } else { - echo ''; - echo ''; - } - return 'getExtra() . '>'; - } - - /** - * Render support for XoopsFormDhtmlTextArea - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered form element - */ - public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) - { - static $js_loaded; - - xoops_loadLanguage('formdhtmltextarea'); - $ret = ''; - // actions - $ret .= $this->renderFormDhtmlTAXoopsCode($element) . "
      \n"; - // fonts - $ret .= $this->renderFormDhtmlTATypography($element); - // length checker - - $ret .= "
      \n"; - // the textarea box - $ret .= "\n"; - - if (empty($element->skipPreview)) { - if (empty($GLOBALS['xoTheme'])) { - $element->js .= implode('', file(XOOPS_ROOT_PATH . '/class/textsanitizer/image/image.js')); - } else { - $GLOBALS['xoTheme']->addScript( - '/class/textsanitizer/image/image.js', - ['type' => 'text/javascript'], - ); - } - $button = ""; - - $ret .= '
      ' . "
      " - . '
      ' . ' ' . $button . '' - . "
      " . _XOOPS_FORM_PREVIEW_CONTENT - . '
      ' . '
      ' . '
      '; - } - // Load javascript - if (empty($js_loaded)) { - $javascript = ($element->js ? '' : '') - . ''; - $ret = $javascript . $ret; - $js_loaded = true; - } - - return $ret; - } - - /** - * Render xoopscode buttons for editor, include calling text sanitizer extensions - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered buttons for xoopscode assistance - */ - protected function renderFormDhtmlTAXoopsCode(XoopsFormDhtmlTextArea $element) - { - $textarea_id = $element->getName(); - $code = ''; - $code .= "
      "; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - $code .= ""; - - $myts = \MyTextSanitizer::getInstance(); - - $extensions = array_filter($myts->config['extensions']); - foreach (array_keys($extensions) as $key) { - $extension = $myts->loadExtension($key); - @[$encode, $js] = $extension->encode($textarea_id); - if (empty($encode)) { - continue; - } - // TODO - MyTextSanitizer button rendering should go through XoopsFormRenderer - $encode = str_replace('btn-default', 'btn-secondary', $encode); - - $code .= $encode; - if (!empty($js)) { - $element->js .= $js; - } - } - $code .= ""; - $code .= ""; - $code .= "
      "; - - $xoopsPreload = XoopsPreload::getInstance(); - $xoopsPreload->triggerEvent('core.class.xoopsform.formdhtmltextarea.codeicon', [&$code]); - - return $code; - } - - /** - * Render typography controls for editor (font, size, color) - * - * @param XoopsFormDhtmlTextArea $element form element - * - * @return string rendered typography controls - */ - protected function renderFormDhtmlTATypography(XoopsFormDhtmlTextArea $element) - { - $textarea_id = $element->getName(); - $hiddentext = $element->_hiddenText; - - $fontarray = !empty($GLOBALS['formtextdhtml_fonts']) ? $GLOBALS['formtextdhtml_fonts'] : [ - 'Arial', - 'Courier', - 'Georgia', - 'Helvetica', - 'Impact', - 'Verdana', - 'Haettenschweiler', - ]; - - $colorArray = [ - 'Black' => '000000', - 'Blue' => '38AAFF', - 'Brown' => '987857', - 'Green' => '79D271', - 'Grey' => '888888', - 'Orange' => 'FFA700', - 'Paper' => 'E0E0E0', - 'Purple' => '363E98', - 'Red' => 'FF211E', - 'White' => 'FEFEFE', - 'Yellow' => 'FFD628', - ]; - - $fontStr = '
      '; - - $styleStr = "
      "; - $styleStr .= ""; - $styleStr .= ""; - $styleStr .= "'; - $styleStr .= "'; - $styleStr .= "
      "; - - $alignStr = "
      "; - $alignStr .= ""; - $alignStr .= ""; - $alignStr .= ""; - $alignStr .= "
      "; - - $fontStr .= " {$styleStr} {$alignStr} \n"; - - $fontStr .= ""; - $fontStr .= "
      "; - - return $fontStr; - } - - /** - * Render support for XoopsFormElementTray - * - * @param XoopsFormElementTray $element form element - * - * @return string rendered form element - */ - public function renderFormElementTray(XoopsFormElementTray $element) - { - $count = 0; - $ret = ''; - foreach ($element->getElements() as $ele) { - if ($count > 0) { - $ret .= $element->getDelimeter(); - } - if ($ele->getCaption() != '') { - $ret .= $ele->getCaption() . ' '; - } - $ret .= $ele->render() . NWLINE; - if (!$ele->isHidden()) { - ++$count; - } - } - $ret .= ''; - return $ret; - } - - /** - * Render support for XoopsFormFile - * - * @param XoopsFormFile $element form element - * - * @return string rendered form element - */ - public function renderFormFile(XoopsFormFile $element) - { - return '' - . 'getExtra() . '>' - . ''; - } - - /** - * Render support for XoopsFormLabel - * - * @param XoopsFormLabel $element form element - * - * @return string rendered form element - */ - public function renderFormLabel(XoopsFormLabel $element) - { - return '
      ' . $element->getValue() . '
      '; - } - - /** - * Render support for XoopsFormPassword - * - * @param XoopsFormPassword $element form element - * - * @return string rendered form element - */ - public function renderFormPassword(XoopsFormPassword $element) - { - return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; - } - - /** - * Render support for XoopsFormRadio - * - * @param XoopsFormRadio $element form element - * - * @return string rendered form element - */ - public function renderFormRadio(XoopsFormRadio $element) - { - $elementName = $element->getName(); - $elementId = $elementName; - - switch ((int) ($element->columns)) { - case 0: - return $this->renderCheckedInline($element, 'radio', $elementId, $elementName); - case 1: - return $this->renderCheckedOneColumn($element, 'radio', $elementId, $elementName); - default: - return $this->renderCheckedColumnar($element, 'radio', $elementId, $elementName); - } - } - - /** - * Render support for XoopsFormSelect - * - * @param XoopsFormSelect $element form element - * - * @return string rendered form element - */ - public function renderFormSelect(XoopsFormSelect $element) - { - $ele_name = $element->getName(); - $ele_title = $element->getTitle(); - $ele_value = $element->getValue(); - $ele_options = $element->getOptions(); - $ret = ''; - - return $ret; - } - - /** - * Render support for XoopsFormText - * - * @param XoopsFormText $element form element - * - * @return string rendered form element - */ - public function renderFormText(XoopsFormText $element) - { - return "getExtra() . '>'; - } - - /** - * Render support for XoopsFormTextArea - * - * @param XoopsFormTextArea $element form element - * - * @return string rendered form element - */ - public function renderFormTextArea(XoopsFormTextArea $element) - { - return "'; - } - - /** - * Render support for XoopsFormTextDateSelect - * - * @param XoopsFormTextDateSelect $element form element - * - * @return string rendered form element - */ - public function renderFormTextDateSelect(XoopsFormTextDateSelect $element) - { - static $included = false; - if (file_exists(XOOPS_ROOT_PATH . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/calendar.php')) { - include_once XOOPS_ROOT_PATH . '/language/' . $GLOBALS['xoopsConfig']['language'] . '/calendar.php'; - } else { - include_once XOOPS_ROOT_PATH . '/language/english/calendar.php'; - } - - $ele_name = $element->getName(); - $ele_value = $element->getValue(false); - if (is_string($ele_value)) { - $display_value = $ele_value; - $ele_value = time(); - } elseif ($ele_value === 0) { - $display_value = ''; - $ele_value = time(); - } else { - $display_value = date(_SHORTDATESTRING, $ele_value); - } - - $jstime = formatTimestamp($ele_value, 'm/d/Y'); - if (isset($GLOBALS['xoTheme']) && is_object($GLOBALS['xoTheme'])) { - $GLOBALS['xoTheme']->addScript('include/calendar.js'); - $GLOBALS['xoTheme']->addStylesheet('include/calendar-blue.css'); - if (!$included) { - $included = true; - $GLOBALS['xoTheme']->addScript('', '', ' - var calendar = null; - - function selected(cal, date) - { - cal.sel.value = date; - } - - function closeHandler(cal) - { - cal.hide(); - Calendar.removeEvent(document, "mousedown", checkCalendar); - } - - function checkCalendar(ev) - { - var el = Calendar.is_ie ? Calendar.getElement(ev) : Calendar.getTargetElement(ev); - for (; el != null; el = el.parentNode) - if (el == calendar.element || el.tagName == "A") break; - if (el == null) { - calendar.callCloseHandler(); Calendar.stopEvent(ev); - } - } - function showCalendar(id) - { - var el = xoopsGetElementById(id); - if (calendar != null) { - calendar.hide(); - } else { - var cal = new Calendar(true, "' . $jstime . '", selected, closeHandler); - calendar = cal; - cal.setRange(1900, 2100); - calendar.create(); - } - calendar.sel = el; - calendar.parseDate(el.value); - calendar.showAtElement(el); - Calendar.addEvent(document, "mousedown", checkCalendar); - - return false; - } - - Calendar._DN = new Array - ("' . _CAL_SUNDAY . '", - "' . _CAL_MONDAY . '", - "' . _CAL_TUESDAY . '", - "' . _CAL_WEDNESDAY . '", - "' . _CAL_THURSDAY . '", - "' . _CAL_FRIDAY . '", - "' . _CAL_SATURDAY . '", - "' . _CAL_SUNDAY . '"); - Calendar._MN = new Array - ("' . _CAL_JANUARY . '", - "' . _CAL_FEBRUARY . '", - "' . _CAL_MARCH . '", - "' . _CAL_APRIL . '", - "' . _CAL_MAY . '", - "' . _CAL_JUNE . '", - "' . _CAL_JULY . '", - "' . _CAL_AUGUST . '", - "' . _CAL_SEPTEMBER . '", - "' . _CAL_OCTOBER . '", - "' . _CAL_NOVEMBER . '", - "' . _CAL_DECEMBER . '"); - - Calendar._TT = {}; - Calendar._TT["TOGGLE"] = "' . _CAL_TGL1STD . '"; - Calendar._TT["PREV_YEAR"] = "' . _CAL_PREVYR . '"; - Calendar._TT["PREV_MONTH"] = "' . _CAL_PREVMNTH . '"; - Calendar._TT["GO_TODAY"] = "' . _CAL_GOTODAY . '"; - Calendar._TT["NEXT_MONTH"] = "' . _CAL_NXTMNTH . '"; - Calendar._TT["NEXT_YEAR"] = "' . _CAL_NEXTYR . '"; - Calendar._TT["SEL_DATE"] = "' . _CAL_SELDATE . '"; - Calendar._TT["DRAG_TO_MOVE"] = "' . _CAL_DRAGMOVE . '"; - Calendar._TT["PART_TODAY"] = "(' . _CAL_TODAY . ')"; - Calendar._TT["MON_FIRST"] = "' . _CAL_DISPM1ST . '"; - Calendar._TT["SUN_FIRST"] = "' . _CAL_DISPS1ST . '"; - Calendar._TT["CLOSE"] = "' . _CLOSE . '"; - Calendar._TT["TODAY"] = "' . _CAL_TODAY . '"; - - // date formats - Calendar._TT["DEF_DATE_FORMAT"] = "' . _SHORTDATESTRING . '"; - Calendar._TT["TT_DATE_FORMAT"] = "' . _SHORTDATESTRING . '"; - - Calendar._TT["WK"] = ""; - '); - } - } - return '
      ' - . 'getExtra() . '>' - . '' - . '' - . '
      '; - } - - /** - * Render support for XoopsThemeForm - * - * @param XoopsThemeForm $form form to render - * - * @return string rendered form - */ - public function renderThemeForm(XoopsThemeForm $form) - { - $ele_name = $form->getName(); - - $ret = '
      '; - $ret .= '
      getExtra() . '>' - . '

      ' . $form->getTitle() . '

      '; - $hidden = ''; - - foreach ($form->getElements() as $element) { - if (!is_object($element)) { // see $form->addBreak() - $ret .= $element; - continue; - } - if ($element->isHidden()) { - $hidden .= $element->render(); - continue; - } - - $ret .= '
      '; - if (($caption = $element->getCaption()) != '') { - $ret .= ''; - } else { - $ret .= '
      '; - } - $ret .= '
      '; - $ret .= $element->render(); - if (($desc = $element->getDescription()) != '') { - $ret .= '

      ' . $desc . '

      '; - } - $ret .= '
      '; - $ret .= '
      '; - } - $ret .= $hidden; - if ($form->getRequired()) { - $ret .= '
      * = Required
      '; - } - $ret .= '
      '; - $ret .= $form->renderValidationJS(true); - - return $ret; - } - - /** - * Support for themed addBreak - * - * @param XoopsThemeForm $form - * @param string $extra pre-rendered content for break row - * @param string $class class for row - * - * @return void - */ - public function addThemeFormBreak(XoopsThemeForm $form, $extra, $class) - { - $class = ($class != '') ? preg_replace('/[^A-Za-z0-9\s\s_-]/i', '', $class) : ''; - $form->addElement('
      ' . $extra . '
      '); - } -} diff --git a/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php b/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php index e6495f261..051507620 100644 --- a/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php +++ b/htdocs/class/xoopseditor/tinymce7/js/tinymce/plugins/xoopsimagemanager/xoopsimagemanager.php @@ -67,7 +67,7 @@ //xoops_load("xoopsmodule"); include_once XOOPS_ROOT_PATH . '/include/cp_functions.php'; include_once XOOPS_ROOT_PATH . '/modules/system/constants.php'; -include_once __DIR__ . '/XoopsFormRendererBootstrap5.php'; +xoops_load('xoopsformrendererbootstrap5'); XoopsFormRenderer::getInstance()->set(new XoopsFormRendererBootstrap5()); @@ -171,6 +171,10 @@ // Add new category - start if ($op === 'addcat' && \Xmf\Request::hasVar('op', 'POST')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if (!$GLOBALS['xoopsSecurity']->check()) { redirect_header($current_file . '?target=' . $target, 3, implode('
      ', $GLOBALS['xoopsSecurity']->getErrors())); } @@ -226,6 +230,10 @@ // Update category - start if ($op === 'updatecat' && \Xmf\Request::hasVar('op', 'POST')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if (!$GLOBALS['xoopsSecurity']->check() || $imgcat_id <= 0) { redirect_header($current_file . '?target=' . $target, 3, implode('
      ', $GLOBALS['xoopsSecurity']->getErrors())); } @@ -287,6 +295,10 @@ // Confirm delete category - start if ($op === 'delcat' && \Xmf\Request::hasVar('op', 'GET')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + xoops_header(); echo ""; xoops_confirm(['op' => 'delcatok', 'imgcat_id' => $imgcat_id, 'target' => $target], $current_file, _MD_RUDELIMGCAT); @@ -297,6 +309,10 @@ // Delete category - start if ($op === 'delcatok' && \Xmf\Request::hasVar('op', 'POST')) { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if (!$GLOBALS['xoopsSecurity']->check()) { redirect_header($current_file . '?target=' . $target, 3, implode('
      ', $GLOBALS['xoopsSecurity']->getErrors())); } @@ -506,6 +522,10 @@ } if ($op === 'editcat') { + if (!$isadmin) { + redirect_header($current_file . '?target=' . $target, 3, _NOPERM); + } + if ($imgcat_id <= 0) { redirect_header($current_file . '?target=' . $target, 1); } From 903a944ba233ba4a3ebefc4a3d10814567fac423 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 3 Aug 2026 01:03:27 -0400 Subject: [PATCH 5/6] test(xoopsform): cover attribute contexts on pinned methods and renderer uniqueness Pinned methods keep their documented text-context exclusion but are now exercised with payloads that cannot express themselves in element text, so their attribute sites stay covered. Adds a per-element attribute-name-set assertion, per-extension coverage that bypasses the config gate, an onclick delimiter assertion, and a test that each renderer class is declared exactly once. --- tests/bootstrap.php | 68 ++- ...XoopsDhtmlToolbarExtensionEscapingTest.php | 287 +++++++++++ .../XoopsFormRendererEscapingTest.php | 454 ++++++++++++++++++ .../XoopsFormRendererUniquenessTest.php | 192 ++++++++ 4 files changed, 998 insertions(+), 3 deletions(-) create mode 100644 tests/unit/htdocs/class/xoopsform/XoopsDhtmlToolbarExtensionEscapingTest.php create mode 100644 tests/unit/htdocs/class/xoopsform/XoopsFormRendererEscapingTest.php create mode 100644 tests/unit/htdocs/class/xoopsform/XoopsFormRendererUniquenessTest.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 91bb785ab..9b880e507 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -62,9 +62,30 @@ if (!defined('_DB_QUERY_ERROR')) { define('_DB_QUERY_ERROR', 'DB Query Error: %s'); } -if (!defined('_MSC_ORIGINAL_IMAGE')) { - define('_MSC_ORIGINAL_IMAGE', 'Original Image'); -} +// `class/textsanitizer/image/image.php` includes its language file at FILE scope, keyed on +// $xoopsConfig['language'], and then uses _MSC_ORIGINAL_IMAGE inside load() -- a fatal in PHP 8 if +// the include failed. Two separate problems follow from that, and both are fixed here: +// +// 1. Nothing set $xoopsConfig, so the path resolved to `language//misc.php` and the include +// always failed. The previous bootstrap papered over it by stubbing _MSC_ORIGINAL_IMAGE -- +// the one constant the reachable branch happens to use. The other _MSC_* constants in that +// file were still undefined, so a config change (allowimage + a theme) turns a passing test +// into a fatal. +// +// 2. Setting $xoopsConfig['language'] alone does NOT fix it. Roughly thirty tests under +// class/auth/ assign `$GLOBALS['xoopsConfig'] = ['debug_mode' => n]` -- replacing the whole +// array -- and unset() it in tearDown. Whether the include succeeds then depends on which +// test happens to load image.php first, which is not a property a bootstrap may have. +// +// So load the real language file HERE, unconditionally. The require_once is the load-bearing +// part: the constants exist before any test can touch $xoopsConfig. The assignment below is only +// so image.php's own include_once resolves to the same realpath and is a no-op rather than a +// warning. misc.php defines its 21 constants unguarded, which is also why stubbing any of them +// here would collide. +if (!isset($GLOBALS['xoopsConfig']['language'])) { + $GLOBALS['xoopsConfig']['language'] = 'english'; +} +require_once XOOPS_ROOT_PATH . '/language/english/misc.php'; if (!defined('_QUOTEC')) { define('_QUOTEC', '"'); } @@ -377,6 +398,47 @@ if (!defined('_XOOPS_FORM_ALT_ENTERHEIGHT')) { define('_XOOPS_FORM_ALT_ENTERHEIGHT', 'Height:'); } + +// TextSanitizer extension button constants. +// +// Only the youtube ones were defined before, because config.dist.php enables youtube and +// disables mp3/wmp/mms/rtsp/soundcloud (wiki is conditional on the mediawiki module) -- so a +// test that rendered the whole toolbar only ever reached youtube. XoopsDhtmlToolbarExtensionEscapingTest +// calls each extension's encode() directly to cover all seven regardless of configuration, +// which needs every extension's constants defined here. +if (!defined('_XOOPS_FORM_ALTMP3')) { + define('_XOOPS_FORM_ALTMP3', 'MP3'); +} +if (!defined('_XOOPS_FORM_ALTWMP')) { + define('_XOOPS_FORM_ALTWMP', 'Windows Media'); +} +if (!defined('_XOOPS_FORM_ENTERWMPURL')) { + define('_XOOPS_FORM_ENTERWMPURL', 'Enter Windows Media URL'); +} +if (!defined('_XOOPS_FORM_ALTMMS')) { + define('_XOOPS_FORM_ALTMMS', 'MMS'); +} +if (!defined('_XOOPS_FORM_ENTERMMSURL')) { + define('_XOOPS_FORM_ENTERMMSURL', 'Enter MMS URL'); +} +if (!defined('_XOOPS_FORM_ALTRTSP')) { + define('_XOOPS_FORM_ALTRTSP', 'RTSP'); +} +if (!defined('_XOOPS_FORM_ENTERRTSPURL')) { + define('_XOOPS_FORM_ENTERRTSPURL', 'Enter RTSP URL'); +} +if (!defined('_XOOPS_FORM_ALT_SOUNDCLOUD')) { + define('_XOOPS_FORM_ALT_SOUNDCLOUD', 'SoundCloud'); +} +if (!defined('_XOOPS_FORM_ENTER_SOUNDCLOUD_URL')) { + define('_XOOPS_FORM_ENTER_SOUNDCLOUD_URL', 'Enter SoundCloud URL'); +} +if (!defined('_XOOPS_FORM_ALTWIKI')) { + define('_XOOPS_FORM_ALTWIKI', 'Wiki'); +} +if (!defined('_XOOPS_FORM_ENTERWIKITERM')) { + define('_XOOPS_FORM_ENTERWIKITERM', 'Enter Wiki term'); +} if (!defined('_XOOPS_FORM_ALTYOUTUBE')) { define('_XOOPS_FORM_ALTYOUTUBE', 'Youtube'); } diff --git a/tests/unit/htdocs/class/xoopsform/XoopsDhtmlToolbarExtensionEscapingTest.php b/tests/unit/htdocs/class/xoopsform/XoopsDhtmlToolbarExtensionEscapingTest.php new file mode 100644 index 000000000..41d99298f --- /dev/null +++ b/tests/unit/htdocs/class/xoopsform/XoopsDhtmlToolbarExtensionEscapingTest.php @@ -0,0 +1,287 @@ + label => [directory, class] + */ + public static function extensionProvider(): array + { + return [ + 'youtube' => ['youtube', 'MytsYoutube'], + 'mp3' => ['mp3', 'MytsMp3'], + 'wmp' => ['wmp', 'MytsWmp'], + 'mms' => ['mms', 'MytsMms'], + 'rtsp' => ['rtsp', 'MytsRtsp'], + 'soundcloud' => ['soundcloud', 'MytsSoundcloud'], + 'wiki' => ['wiki', 'MytsWiki'], + ]; + } + + /** Load an extension class directly, sidestepping MyTextSanitizer's config gate. */ + private function loadExtension(string $dir, string $class): object + { + $file = XOOPS_ROOT_PATH . '/class/textsanitizer/' . $dir . '/' . $dir . '.php'; + self::assertFileExists($file, "Extension $dir is missing from the tree."); + + require_once XOOPS_ROOT_PATH . '/class/module.textsanitizer.php'; + require_once $file; + + self::assertTrue(class_exists($class, false), "Extension $dir did not declare $class."); + + // MyTextSanitizerExtension::__construct() takes the sanitizer; core builds + // extensions as `new $class($this)` from inside MyTextSanitizer. + return new $class(\MyTextSanitizer::getInstance()); + } + + /** `encode()` returns [buttonHtml, javascript]; only the button markup carries handlers. */ + private function buttonHtmlFor(string $dir, string $class): string + { + $encoded = $this->loadExtension($dir, $class)->encode(self::MALICIOUS_NAME); + $html = is_array($encoded) ? (string) ($encoded[0] ?? '') : (string) $encoded; + + self::assertNotSame('', $html, "$dir::encode() produced no button markup."); + + return $html; + } + + /** + * Strip JS string literals, leaving the code around them. + * + * This is what separates "the payload is present" from "the payload is executable". A + * correctly escaped handler still CONTAINS the characters `alert(1)` -- inside a string + * literal, as inert data: + * + * xoopsCodeYoutube("x\u0022);alert(1);\/\/","Enter YouTube URL","Height:","Enter Width"); + * + * So asserting that `alert(1)` is absent from the raw handler FAILS ON CORRECT OUTPUT, and + * the shortest way to make such an assertion pass is to weaken the escaping. It is the same + * mistake as grepping rendered HTML for `onfocus=` and calling it a breakout when the output + * was `value='x' onfocus='...'` -- correctly escaped and inert. + * + * Removing the literals first asks the question that matters: is the payload CODE? A payload + * that closed its literal leaves `);alert(1);//` behind here; one that did not leaves an + * empty argument list. + * + * Deliberately small: it tracks the opening quote and honours backslash escapes, which is all + * a one-line call expression needs. An unterminated literal swallows the rest of the string, + * which is itself the signature of a break-out and shows up as a missing `)`. + */ + private function codeOutsideStringLiterals(string $js): string + { + $out = ''; + $quote = null; + $length = strlen($js); + + for ($i = 0; $i < $length; ++$i) { + $char = $js[$i]; + + if (null !== $quote) { + if ('\\' === $char) { + ++$i; // skip the escaped character, whatever it is + continue; + } + if ($char === $quote) { + $quote = null; + } + continue; + } + + if ('"' === $char || "'" === $char) { + $quote = $char; + continue; + } + + $out .= $char; + } + + return $out; + } + + /** @return array the onclick attribute bodies in the given markup */ + private function onclickBodies(string $html): array + { + preg_match_all("/onclick='([^']*)'/", $html, $matches); + + return $matches[1]; + } + + #[Test] + #[DataProvider('extensionProvider')] + public function eachExtensionEmitsInertJavaScriptForAHostileTextareaId(string $dir, string $class): void + { + $handlers = $this->onclickBodies($this->buttonHtmlFor($dir, $class)); + self::assertNotEmpty($handlers, "$dir::encode() produced no single-quoted onclick handler."); + + foreach ($handlers as $handler) { + // What the JS parser actually receives: the HTML attribute decode happens first. + $decoded = html_entity_decode($handler, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + self::assertDoesNotMatchRegularExpression( + '/"\s*\)\s*;\s*alert\s*\(/', + $decoded, + "$dir: the injected payload closed its JS string literal, making alert( callable" + ); + self::assertStringNotContainsString( + 'alert', + $this->codeOutsideStringLiterals($decoded), + "$dir: the injected payload escaped its JS string literal and became executable " + . "code. The payload is EXPECTED to appear inside the literal -- that is what " + . "correct escaping looks like -- so this fires only when it appears outside one." + ); + } + } + + #[Test] + #[DataProvider('extensionProvider')] + public function eachExtensionEmitsAWellFormedCallExpression(string $dir, string $class): void + { + foreach ($this->onclickBodies($this->buttonHtmlFor($dir, $class)) as $handler) { + $decoded = html_entity_decode($handler, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + self::assertMatchesRegularExpression( + '/^\s*[A-Za-z_$][\w$]*\s*\(.*\)\s*;?\s*$/s', + $decoded, + "$dir: the onclick handler is not a single well-formed call expression" + ); + // Counted OUTSIDE string literals. The hostile textarea id carries its own + // parentheses, so a naive count over the whole handler reports an imbalance on + // perfectly correct output -- and the shortest way to make that pass is to stop + // escaping. + $code = $this->codeOutsideStringLiterals($decoded); + + self::assertSame( + substr_count($code, '('), + substr_count($code, ')'), + "$dir: unbalanced parentheses in the onclick handler -- it would throw on click" + ); + } + } + + /** The delimiter invariant the json_encode design silently depends on. See the class docblock. */ + #[Test] + #[DataProvider('extensionProvider')] + public function eachExtensionUsesSingleQuotedOnclickAttributes(string $dir, string $class): void + { + self::assertDoesNotMatchRegularExpression( + '/onclick\s*=\s*"/', + $this->buttonHtmlFor($dir, $class), + "$dir: onclick is double-quoted. json_encode wraps its output in double quotes, so the " + . "first argument closes the attribute and JSON_HEX_QUOT does not help. Use onclick='...'." + ); + } + + /** The same invariant for the toolbar's own buttons and dropdowns. */ + #[Test] + public function theToolbarItselfUsesSingleQuotedOnclickAttributes(): void + { + $element = new XoopsFormDhtmlTextArea('Caption', self::MALICIOUS_NAME, 'value', 5, 50, 'xoopsHiddenText'); + $toolbar = new XoopsDhtmlToolbar(); + $html = $toolbar->renderCodeButtons($element) . $toolbar->renderTypography($element); + + self::assertDoesNotMatchRegularExpression('/onclick\s*=\s*"/', $html, 'Toolbar emitted a double-quoted onclick.'); + self::assertNotEmpty($this->onclickBodies($html), 'Toolbar emitted no single-quoted onclick handlers.'); + } + + /** + * Whole-toolbar smoke test. Kept because it exercises the core buttons the per-extension + * tests do not reach -- but it is NOT the coverage guarantee; see the class docblock. + */ + #[Test] + public function toolbarOnclickHandlersSurviveAttributeDecodeWithoutExecutingInjectedCode(): void + { + $element = new XoopsFormDhtmlTextArea('Caption', self::MALICIOUS_NAME, 'value', 5, 50, 'xoopsHiddenText'); + $toolbar = new XoopsDhtmlToolbar(); + $handlers = $this->onclickBodies($toolbar->renderCodeButtons($element)); + + self::assertNotEmpty($handlers, 'No onclick handlers were found in the rendered toolbar markup.'); + + foreach ($handlers as $handler) { + $decoded = html_entity_decode($handler, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + preg_match('/^([A-Za-z0-9_]+)\(/', $decoded, $fnMatch); + $handlerName = $fnMatch[1] ?? $handler; + + self::assertDoesNotMatchRegularExpression( + '/"\s*\)\s*;\s*alert\s*\(/', + $decoded, + "{$handlerName}: injected payload closed its JS string literal, letting alert( become callable" + ); + self::assertStringNotContainsString( + 'alert', + $this->codeOutsideStringLiterals($decoded), + "{$handlerName}: the injected payload escaped its JS string literal and became " + . "executable code" + ); + } + } +} diff --git a/tests/unit/htdocs/class/xoopsform/XoopsFormRendererEscapingTest.php b/tests/unit/htdocs/class/xoopsform/XoopsFormRendererEscapingTest.php new file mode 100644 index 000000000..affd0c39f --- /dev/null +++ b/tests/unit/htdocs/class/xoopsform/XoopsFormRendererEscapingTest.php @@ -0,0 +1,454 @@ + body. + * + * Every renderer interpolated $element->getValue() straight into a quoted attribute (button, + * color picker, password, text) and straight into a ", + * broke out of the element and the rest parsed as markup. Any field whose value can come + * from user input -- a search box redisplaying its term, a profile field after a validation + * failure, a comment preview -- was an injection point. + * + * WHAT IS ASSERTED + * ---------------- + * Attribute sites: containment, measured against a benign baseline render of the same + * element. A hostile value may not ADD an element, a ` must not + * reach the output as `', + 'entity then image' => '`">', + 'whitespace and amp' => "a\nb\tc & d", + ]; + + /** + * Payloads carrying no '<', so they are inert in element TEXT but still close a quoted + * attribute. + * + * KNOWN_TEXT_CONTEXT_GAPS excludes a METHOD; the gap it documents is a CONTEXT. Bootstrap4 + * and Bootstrap5 renderFormButton also write value= and title= attributes, so skipping the + * whole method dropped the containment assertion from those too -- reverting just those two + * attribute sites to raw getValue() left this suite green with a live breakout reintroduced. + * These payloads restore attribute coverage on the pinned methods without asserting anything + * about the text gap they legitimately document. + */ + private const ATTRIBUTE_ONLY_PAYLOADS = [ + 'single quote breakout' => "x' onfocus='alert(1)' autofocus x", + 'double quote breakout' => 'x" onfocus="alert(1)" autofocus x', + ]; + + /** Value that closes a '; + + /** + * renderer short name => render method, for text contexts still unescaped. + * + * These are asserted to STILL be injectable, so that closing the gap fails + * this test and forces the exclusion to be removed rather than leaving a + * silently over-broad skip behind. + */ + private const KNOWN_TEXT_CONTEXT_GAPS = [ + 'Bootstrap4' => ['renderFormButton', 'renderFormButtonTray'], + 'Bootstrap5' => ['renderFormButton', 'renderFormButtonTray'], + ]; + + /** + * Element factories for the methods listed in {@see self::KNOWN_TEXT_CONTEXT_GAPS}, keyed + * by render method name, used only by the pin test below (these methods are not exercised + * by {@see self::aHostileValueCannotLeaveItsAttribute} because their value lands in + * element TEXT, not an attribute). + * + * @return array + */ + private function knownGapFactories(): array + { + return [ + 'renderFormButton' => static fn (string $v) => new \XoopsFormButton('caption', 'fld', $v, 'submit'), + 'renderFormButtonTray' => static fn (string $v) => new \XoopsFormButtonTray('fld', $v, 'submit'), + ]; + } + + public static function rendererProvider(): array + { + return [ + 'Legacy' => ['Legacy'], + 'Bootstrap3' => ['Bootstrap3'], + 'Bootstrap4' => ['Bootstrap4'], + 'Bootstrap5' => ['Bootstrap5'], + 'Tailwind' => ['Tailwind'], + ]; + } + + /** + * The elements whose value lands in an HTML attribute on every renderer. + * + * @return array + */ + private function elementFactories(): array + { + return [ + 'renderFormButton' => static fn (string $v) => new \XoopsFormButton('caption', 'fld', $v, 'submit'), + 'renderFormColorPicker' => static fn (string $v) => new \XoopsFormColorPicker('caption', 'fld', $v), + 'renderFormPassword' => static fn (string $v) => new \XoopsFormPassword('caption', 'fld', 30, 255, $v), + 'renderFormText' => static fn (string $v) => new \XoopsFormText('caption', 'fld', 30, 255, $v), + ]; + } + + #[Test] + #[DataProvider('rendererProvider')] + public function aHostileValueCannotLeaveItsAttribute(string $shortName): void + { + $class = 'XoopsFormRenderer' . $shortName; + $renderer = new $class(); + $skip = self::KNOWN_TEXT_CONTEXT_GAPS[$shortName] ?? []; + $checked = 0; + + foreach ($this->elementFactories() as $method => $make) { + if (!method_exists($renderer, $method)) { + continue; + } + + // A pinned method still writes attributes; only its TEXT context is excluded, so it + // gets the payloads that cannot express themselves in text. See ATTRIBUTE_ONLY_PAYLOADS. + $payloads = in_array($method, $skip, true) ? self::ATTRIBUTE_ONLY_PAYLOADS : self::PAYLOADS; + + // Burn any one-shot asset emission so it is not counted as injection. + $this->render($renderer, $method, $make('warmup')); + $baseline = $this->shapeOf($this->render($renderer, $method, $make('benign'))); + + foreach ($payloads as $label => $payload) { + $shape = $this->shapeOf($this->render($renderer, $method, $make($payload))); + $where = sprintf('%s::%s with the "%s" payload', $class, $method, $label); + + self::assertLessThanOrEqual($baseline['elements'], $shape['elements'], "An element was injected by $where"); + self::assertLessThanOrEqual($baseline['scripts'], $shape['scripts'], "A