diff --git a/htdocs/class/textsanitizer/mms/mms.php b/htdocs/class/textsanitizer/mms/mms.php index 053194865..7ce8e315c 100644 --- a/htdocs/class/textsanitizer/mms/mms.php +++ b/htdocs/class/textsanitizer/mms/mms.php @@ -35,14 +35,15 @@ public function encode($textarea_id) if ($config['enable_mms_entry'] === false) { return []; } - $code = ""; - //$code = "" . _XOOPS_FORM_ALTMMS . " "; $javascript = <<" . ""; diff --git a/htdocs/class/textsanitizer/readme.txt b/htdocs/class/textsanitizer/readme.txt index 3b8e14062..7f9bb44c4 100644 --- a/htdocs/class/textsanitizer/readme.txt +++ b/htdocs/class/textsanitizer/readme.txt @@ -16,7 +16,20 @@ class MytsMycode extends MyTextSanitizerExtension // If the extension has config data, load it $config = parent::loadConfig(__DIR__); // Make sure that the icon is available /images/form/mycode.gif - $code = "" . _XOOPS_FORM_ALTMYCODE . " "; + // Arguments crossing from PHP into a JavaScript string literal MUST be json_encode()d + // with these four flags, NOT htmlspecialchars()d. The browser decodes HTML entities + // BEFORE the JS parser runs, so an entity-escaped quote becomes a real quote and can + // close the literal. json_encode emits \uXXXX escapes, which survive that decode. + // + // The surrounding onclick attribute MUST be single-quoted: json_encode wraps its own + // output in double quotes, so onclick="..." would be closed by the first argument and + // JSON_HEX_QUOT would not help -- it escapes quotes in the VALUE, not the wrapping pair. + $jsFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR; + $args = json_encode((string) $textarea_id, $jsFlags) + . ', ' . json_encode(_XOOPS_FORM_ENTERMYCODETERM, $jsFlags); + $code = ""
+              . htmlspecialchars(_XOOPS_FORM_ALTMYCODE, ENT_QUOTES | ENT_HTML5,  "; $javascript = <<"; - // $code = "" . _XOOPS_FORM_ALTRTSP . " "; $javascript = <<"; $javascript = <<"; $javascript = <<"; - //$code = "" . _XOOPS_FORM_ALTWMP . " "; $javascript = <<"; $javascript = <<`, 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 + { + // Raw name deliberately (getName(false)): the value is escaped per-context downstream — + // json_encode() inside jsCall() for JS arguments, rawurlencode() for the URL query params + // below — so do NOT "helpfully" restore the default HTML-encoded getName() here. + $textareaId = $element->getName(false); + $btn = self::BTN_SM; + + $code = ''; + // No aria-label/role="group" here (unlike the other groups in this class): the group + // holds URL, email, image, image-manager, smilies, an open-ended list of TextSanitizer + // extension buttons, code and quote -- there is no single existing language constant + // that describes it, and concatenating one from the individual button labels (as the + // typography groups below do) isn't possible because the extension buttons are appended + // dynamically. Adding a new constant would require a docs/lang_diff.txt entry for a + // one-off label, so each button's own aria-label carries the meaning instead. + $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=' . rawurlencode($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=' . rawurlencode($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 + { + // Raw name deliberately (getName(false)): the value is escaped per-context downstream — + // json_encode() inside jsCall() for every JS argument below — so do NOT "helpfully" + // restore the default HTML-encoded getName() here. + $textareaId = $element->getName(false); + $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; + // Unlike the code-buttons group above, this group's contents are a fixed, known set, so + // (as with the size/font/colour and alignment groups) the aria-label is built from the + // concatenation of the individual button labels rather than dropped. + $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']; + } + + // Raw name deliberately (getName(false)): the value is escaped per-context downstream — + // json_encode() inside jsCall() for JS arguments — so do NOT "helpfully" restore the + // default HTML-encoded getName() here. + $onclick = $this->jsCall('XoopsCheckLength', [$element->getName(false), (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 JSON-encoded so the result is safe inside a single-quoted `onclick='...'` attribute. + * Integer/float arguments are emitted unquoted. + * + * The value crosses two decoders before it runs: the browser's HTML-attribute decoder, then the + * JS parser. `htmlspecialchars()` only escapes for the first — the browser decodes `"` back + * to `"` before the JS parser ever sees it, so a string argument containing `"` can close the JS + * string literal early (e.g. `x");alert(1);//`) and inject arbitrary script. `json_encode()` + * with the `JSON_HEX_*` flags instead emits `"`, `<`, `&`, `'` — sequences + * that survive HTML-attribute decoding unchanged, so the value stays a single JS string literal + * no matter which decoder runs first. json_encode() also supplies its own surrounding quotes. + * + * @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[] = json_encode((string) $arg, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); + } + } + + 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..5b40f17a1 --- /dev/null +++ b/htdocs/class/xoopseditor/dhtmltextarea/assets/toolbar.css @@ -0,0 +1,243 @@ +/** + * 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) + */ + +/* + * The custom properties below are declared twice on purpose: once here on :root, and again + * (identically) on .xo-edtb-toolbar. Do NOT "de-duplicate" this by deleting one of the two. + * + * XoopsDhtmlToolbar::render() wraps everything in a .xo-edtb-toolbar container, so the block + * below is enough for that path. But this PR deliberately keeps a compatibility path for + * third-party subclasses: renderFormDhtmlTAXoopsCode() and renderFormDhtmlTATypography() call + * XoopsDhtmlToolbar::renderCodeButtons()/renderTypography() directly and return bare + * .xo-edtb-group rows with NO .xo-edtb-toolbar ancestor. Without a :root fallback, every + * variable those rows reference (min-width, height, background, border, ...) resolves against + * an undefined custom property, and the buttons render unstyled. The :root copy is the fallback + * for that path; the .xo-edtb-toolbar copy is kept so a theme can still override the variables + * per-toolbar (its higher specificity wins whenever a .xo-edtb-toolbar ancestor is present). + */ +:root { + --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); +} + +.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); +} + +/* + * Explicit keyboard-focus indicator. The admin themes ship a universal reset that commonly + * drops the default `outline` (see the resize-grip comment above for the same class of + * problem), leaving keyboard users with no visible indication of which control has focus. + * --xo-edtb-btn-fg is reused (rather than a hardcoded colour) because it already flips from a + * dark foreground in the light scheme to a light foreground in the dark scheme + * (@media (prefers-color-scheme: dark) below), which keeps the ring visible — and themeable — + * against both of this file's button/background colour pairs. + */ +.xo-edtb-btn:focus-visible, +.xo-edtb-menu-item:focus-visible { + outline: 2px solid var(--xo-edtb-btn-fg); + outline-offset: 2px; +} + +.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/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); } diff --git a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php index 8eb88ed7e..9738459aa 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap3.php @@ -9,6 +9,8 @@ */ require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; +require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; /** * Bootstrap3 style form renderer @@ -21,6 +23,8 @@ */ class XoopsFormRendererBootstrap3 implements XoopsFormRendererInterface, XoopsFormTabRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Counter giving each rendered tab tray a unique DOM id. * @@ -37,8 +41,8 @@ class XoopsFormRendererBootstrap3 implements XoopsFormRendererInterface, XoopsFo public function renderFormButton(XoopsFormButton $element) { return "getExtra() . ' />'; + . $element->getName() . "' id='" . $element->getName() . "' value='" . $this->escapeElementValue($element->getValue()) + . "' title='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } /** @@ -59,7 +63,7 @@ public function renderFormButtonTray(XoopsFormButtonTray $element) . '" onClick="history.go(-1);return true;" /> ' . ' ' . 'getExtra() + . '" id="' . $element->getName() . '" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . ' />'; return $ret; @@ -215,7 +219,7 @@ public function renderFormColorPicker(XoopsFormColorPicker $element) } return 'getExtra() . ' />'; + . '" size="7" maxlength="7" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . ' />'; } /** @@ -229,20 +233,16 @@ 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"; + . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -285,146 +285,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 .= ""; - $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; - - $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"; - - $maxlength = $element->configs['maxlength'] ?? 0; - $fontStr .= ""; - $fontStr .= "
      "; - - return $fontStr; + $toolbar = new \XoopsDhtmlToolbar(); + + return $toolbar->renderTypography($element) . $toolbar->renderCheckLength($element); } /** @@ -507,7 +396,7 @@ public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -578,7 +467,7 @@ public function renderFormText(XoopsFormText $element) return "getExtra() . ' />'; + . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } /** @@ -593,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 76d953e05..7f70fea9b 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap4.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap4.php @@ -9,6 +9,8 @@ */ require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; +require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; /** * Bootstrap4 style form renderer @@ -22,6 +24,8 @@ */ class XoopsFormRendererBootstrap4 implements XoopsFormRendererInterface, XoopsFormTabRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Counter giving each rendered tab tray a unique DOM id. * @@ -39,8 +43,8 @@ public function renderFormButton(XoopsFormButton $element) { return ''; } @@ -220,7 +224,7 @@ public function renderFormColorPicker(XoopsFormColorPicker $element) } return 'getExtra() . '>'; + . '" size="7" maxlength="7" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . '>'; } /** @@ -234,20 +238,16 @@ 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"; + . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -290,149 +290,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; - } - // 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; + 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; - - $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"; - - $maxlength = $element->configs['maxlength'] ?? 0; - $fontStr .= ""; - $fontStr .= "
      "; - - return $fontStr; + $toolbar = new \XoopsDhtmlToolbar(); + + return $toolbar->renderTypography($element) . $toolbar->renderCheckLength($element); } /** @@ -513,7 +399,7 @@ public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -585,7 +471,7 @@ public function renderFormText(XoopsFormText $element) return "getExtra() . '>'; + . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . '>'; } /** @@ -600,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 f4a23ccd3..3975ece98 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap5.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererBootstrap5.php @@ -9,6 +9,8 @@ */ require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; +require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; /** * Bootstrap5 style form renderer @@ -22,6 +24,8 @@ */ class XoopsFormRendererBootstrap5 implements XoopsFormRendererInterface, XoopsFormTabRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Counter giving each rendered tab tray a unique DOM id. * @@ -40,8 +44,8 @@ public function renderFormButton(XoopsFormButton $element) { return ''; } @@ -221,7 +225,7 @@ public function renderFormColorPicker(XoopsFormColorPicker $element) } return 'getExtra() . '>'; + . '" size="7" maxlength="7" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . '>'; } /** @@ -235,20 +239,16 @@ 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"; + . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -291,149 +291,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; - } - // 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; + 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; - - $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"; - - $maxlength = $element->configs['maxlength'] ?? 0; - $fontStr .= ""; - $fontStr .= "
      "; - - return $fontStr; + $toolbar = new \XoopsDhtmlToolbar(); + + return $toolbar->renderTypography($element) . $toolbar->renderCheckLength($element); } /** @@ -514,7 +400,7 @@ public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -586,7 +472,7 @@ public function renderFormText(XoopsFormText $element) return "getExtra() . '>'; + . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . '>'; } /** @@ -601,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 00a4de9ba..8e3eb6f4c 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererLegacy.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererLegacy.php @@ -17,8 +17,13 @@ * @copyright 2000-2026 XOOPS Project (https://xoops.org) * @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 * @@ -29,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() . ' />'; } /** @@ -50,7 +55,7 @@ public function renderFormButtonTray(XoopsFormButtonTray $element) $ret .= ' ' . ' ' . 'getExtra() + . '" id="' . $element->getName() . '" value="' . $this->escapeElementValue($element->getValue()) . '"' . $element->getExtra() . ' />'; return $ret; @@ -141,7 +146,7 @@ public function renderFormColorPicker(XoopsFormColorPicker $element) } return "getExtra() + . $element->getMaxlength() . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } @@ -156,16 +161,11 @@ public function renderFormDhtmlTextArea(XoopsFormDhtmlTextArea $element) { xoops_loadLanguage('formdhtmltextarea'); $ret = ''; - // actions - $ret .= $this->renderFormDhtmlTAXoopsCode($element) . "
      \n"; - // fonts - $ret .= $this->renderFormDhtmlTATypography($element); - // length checker - $maxlength = $element->configs['maxlength'] ?? 0; - $ret .= " "; - $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"; + $ret .= "
      \n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -200,101 +200,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); } /** @@ -366,7 +300,7 @@ public function renderFormLabel(XoopsFormLabel $element) public function renderFormPassword(XoopsFormPassword $element) { return 'getExtra() . ' ' . ($element->autoComplete ? '' : 'autocomplete="off" ') . '/>'; } @@ -468,7 +402,7 @@ public function renderFormText(XoopsFormText $element) { return "getExtra() + . $element->getMaxlength() . "' value='" . $this->escapeElementValue($element->getValue()) . "'" . $element->getExtra() . ' />'; } @@ -483,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 fc03919ab..dacf279db 100644 --- a/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php +++ b/htdocs/class/xoopsform/renderer/XoopsFormRendererTailwind.php @@ -20,6 +20,8 @@ defined('XOOPS_ROOT_PATH') || exit('Restricted access'); require_once __DIR__ . '/XoopsFormTabRendererInterface.php'; +require_once __DIR__ . '/../../xoopseditor/dhtmltextarea/XoopsDhtmlToolbar.php'; +require_once __DIR__ . '/XoopsFormRendererValueEscapeTrait.php'; /** * Tailwind CSS + DaisyUI form renderer @@ -43,6 +45,8 @@ */ class XoopsFormRendererTailwind implements XoopsFormRendererInterface, XoopsFormTabRendererInterface { + use XoopsFormRendererValueEscapeTrait; + /** * Counter giving each rendered tab tray a unique DOM id / radio group. * @@ -409,16 +413,16 @@ 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->renderExtra($element) . '>' . $this->escapeElementValue($element->getValue()) . "\n"; if (empty($element->skipPreview)) { if (empty($GLOBALS['xoTheme'])) { @@ -496,142 +500,35 @@ protected function renderEditorButton(string $class, string $onclickJs, string $ /** * 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) { - $textareaIdRaw = (string) $element->getName(false); - $textareaIdParam = rawurlencode($textareaIdRaw); - $urlImageMgr = XOOPS_URL . '/imagemanager.php?target=' . $textareaIdParam; - $urlSmilies = XOOPS_URL . '/misc.php?action=showpopups&type=smilies&target=' . $textareaIdParam; - $btn = self::BTN_NEUTRAL_SM; - - $code = "
      "; - $code .= $this->renderEditorButton($btn, $this->buildJsCall('xoopsCodeUrl', [$textareaIdRaw, _ENTERURL, _ENTERWEBTITLE]), _XOOPS_FORM_ALT_URL, 'fa-solid fa-link'); - $code .= $this->renderEditorButton($btn, $this->buildJsCall('xoopsCodeEmail', [$textareaIdRaw, _ENTEREMAIL, _ENTERWEBTITLE]), _XOOPS_FORM_ALT_EMAIL, 'fa-solid fa-envelope'); - $code .= $this->renderEditorButton($btn, $this->buildJsCall('xoopsCodeImg', [$textareaIdRaw, _ENTERIMGURL, _ENTERIMGPOS, _IMGPOSRORL, _ERRORIMGPOS, _XOOPS_FORM_ALT_ENTERWIDTH]), _XOOPS_FORM_ALT_IMG, 'fa-solid fa-file-image'); - $code .= $this->renderEditorButton($btn, $this->buildJsCall('openWithSelfMain', [$urlImageMgr, 'imgmanager', 400, 430]), _XOOPS_FORM_ALT_IMAGE, 'fa-solid fa-file-image', ' Manager'); - $code .= $this->renderEditorButton($btn, $this->buildJsCall('openWithSelfMain', [$urlSmilies, '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); - $result = $extension->encode($textareaIdRaw); - $encode = $result[0] ?? ''; - $js = $result[1] ?? ''; - if (empty($encode)) { - continue; - } - // Extensions output Bootstrap classes — remap the common ones to DaisyUI. - $encode = str_replace(['btn-default', 'btn-secondary'], self::BTN_NEUTRAL_SM, $encode); - $code .= $encode; - if (!empty($js)) { - $element->js .= $js; - } - } - $code .= $this->renderEditorButton($btn, $this->buildJsCall('xoopsCodeCode', [$textareaIdRaw, _ENTERCODE]), _XOOPS_FORM_ALT_CODE, 'fa-solid fa-code'); - $code .= $this->renderEditorButton($btn, $this->buildJsCall('xoopsCodeQuote', [$textareaIdRaw, _ENTERQUOTE]), _XOOPS_FORM_ALT_QUOTE, 'fa-solid fa-quote-right'); - $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) { - $textareaIdRaw = (string) $element->getName(false); - $hiddentextRaw = (string) $element->_hiddenText; - $btn = self::BTN_NEUTRAL_SM; - $menuCls = self::DROPDOWN_MENU_CLS; - - $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 = "
      "; - - // Size dropdown — each link uses href='#' onclick='...; return false;' - // so the JavaScript runs through a real handler context instead of a - // `javascript:` URL (which adds URL decoding on top of HTML entity - // decoding before the JS engine sees it). - $sizes = $GLOBALS['formtextdhtml_sizes'] ?? []; - $fontStr .= "'; - - // Font dropdown - $fontStr .= "'; - - // Color dropdown - $fontStr .= "'; - - // Style buttons - $styleBtn = self::BTN_NEUTRAL_SM . ' join-item'; - $fontStr .= "
      "; - $fontStr .= $this->renderEditorButton($styleBtn, $this->buildJsCall('xoopsMakeBold', [$hiddentextRaw, $textareaIdRaw]), _XOOPS_FORM_ALT_BOLD, 'fa-solid fa-bold'); - $fontStr .= $this->renderEditorButton($styleBtn, $this->buildJsCall('xoopsMakeItalic', [$hiddentextRaw, $textareaIdRaw]), _XOOPS_FORM_ALT_ITALIC, 'fa-solid fa-italic'); - $fontStr .= $this->renderEditorButton($styleBtn, $this->buildJsCall('xoopsMakeUnderline', [$hiddentextRaw, $textareaIdRaw]), _XOOPS_FORM_ALT_UNDERLINE, 'fa-solid fa-underline'); - $fontStr .= $this->renderEditorButton($styleBtn, $this->buildJsCall('xoopsMakeLineThrough', [$hiddentextRaw, $textareaIdRaw]), _XOOPS_FORM_ALT_LINETHROUGH, 'fa-solid fa-strikethrough'); - $fontStr .= '
      '; - - // Align buttons - $fontStr .= "
      "; - $fontStr .= $this->renderEditorButton($styleBtn, $this->buildJsCall('xoopsMakeLeft', [$hiddentextRaw, $textareaIdRaw]), _XOOPS_FORM_ALT_LEFT, 'fa-solid fa-align-left'); - $fontStr .= $this->renderEditorButton($styleBtn, $this->buildJsCall('xoopsMakeCenter', [$hiddentextRaw, $textareaIdRaw]), _XOOPS_FORM_ALT_CENTER, 'fa-solid fa-align-center'); - $fontStr .= $this->renderEditorButton($styleBtn, $this->buildJsCall('xoopsMakeRight', [$hiddentextRaw, $textareaIdRaw]), _XOOPS_FORM_ALT_RIGHT, 'fa-solid fa-align-right'); - $fontStr .= '
      '; - - // Length check button — configs is a legacy dynamic property on some - // editor instances; guard the access to avoid PHP 8.2 dynamic property warnings - $maxlength = 0; - if (property_exists($element, 'configs') && is_array($element->configs) && isset($element->configs['maxlength'])) { - $maxlength = (int) $element->configs['maxlength']; - } - $lengthOnclick = $this->buildJsCall('XoopsCheckLength', [$textareaIdRaw, (string) $maxlength, _XOOPS_FORM_ALT_LENGTH, _XOOPS_FORM_ALT_LENGTH_MAX]); - $checkLengthLabel = $this->esc(_XOOPS_FORM_ALT_CHECKLENGTH); - $fontStr .= ""; - $fontStr .= '
      '; + $toolbar = new \XoopsDhtmlToolbar(); - return $fontStr; + return $toolbar->renderTypography($element) . $toolbar->renderCheckLength($element); } /** @@ -824,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' + ); + } +} 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); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index adbe5bfd1..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', '"'); } @@ -374,6 +395,53 @@ if (!defined('_XOOPS_FORM_ENTERYOUTUBEURL')) { define('_XOOPS_FORM_ENTERYOUTUBEURL', 'Enter YouTube URL'); } +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'); +} // Multibyte constants if (!defined('XOOPS_USE_MULTIBYTES')) { @@ -971,3 +1039,13 @@ function xoops_load($name, $type = 'core') { return XoopsLoad::load($name, $type); } + +// Xmf\I18n\Xoops\Registry\LocaleRegistry caches a snapshot under XOOPS_VAR_PATH, which in this +// suite resolves to the repo's own xoops_data/. A developer who also runs a live site from this +// tree leaves a populated snapshot there, and tests would then negotiate locales and build language +// selectors from THAT site's installed packs — passing or failing differently per machine. Purge it +// once per test process so every run starts from an unreadable (empty) registry. Nothing here has a +// database, so no test rewrites the file. +if (class_exists(\Xmf\I18n\Xoops\Registry\LocaleRegistry::class)) { + (new \Xmf\I18n\Xoops\Registry\LocaleRegistry(null))->purgeCache(); +} 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/XoopsDhtmlToolbarTest.php b/tests/unit/htdocs/class/xoopsform/XoopsDhtmlToolbarTest.php new file mode 100644 index 000000000..145e87dc3 --- /dev/null +++ b/tests/unit/htdocs/class/xoopsform/XoopsDhtmlToolbarTest.php @@ -0,0 +1,349 @@ +3rd'; + } +} + +/** + * Unit tests for the shared {@see XoopsDhtmlToolbar}. + * + * Test 1 is the acceptance test for the whole refactor: all five form renderers must delegate to + * the shared toolbar and therefore produce byte-identical toolbar markup for the same element. + */ +class XoopsDhtmlToolbarTest extends TestCase +{ + private const RENDERER_CLASSES = [ + 'XoopsFormRendererLegacy', + 'XoopsFormRendererBootstrap3', + 'XoopsFormRendererBootstrap4', + 'XoopsFormRendererBootstrap5', + 'XoopsFormRendererTailwind', + ]; + + /** @var array */ + private $originalPreloadEvents; + + protected function setUp(): void + { + $GLOBALS['formtextdhtml_sizes'] = [ + 'xx-small' => 'xx-Small', + 'small' => 'Small', + 'medium' => 'Medium', + ]; + $GLOBALS['formtextdhtml_fonts'] = ['Arial', 'Courier', 'Georgia']; + + $instance = XoopsPreload::getInstance(); + $ref = new ReflectionClass($instance); + $prop = $ref->getProperty('_events'); + $prop->setAccessible(true); + $this->originalPreloadEvents = $prop->getValue($instance); + + ToolbarCodeiconTestPreload::$called = false; + + $this->resetStylesheetGuard(); + } + + protected function tearDown(): void + { + $instance = XoopsPreload::getInstance(); + $ref = new ReflectionClass($instance); + $prop = $ref->getProperty('_events'); + $prop->setAccessible(true); + $prop->setValue($instance, $this->originalPreloadEvents); + + unset($GLOBALS['formtextdhtml_sizes'], $GLOBALS['formtextdhtml_fonts']); + } + + // ========================================================================= + // Test 1 — the acceptance test + // ========================================================================= + + /** + * All five renderers must produce the IDENTICAL toolbar string for the same element. This is + * the whole point of the refactor. + */ + public function testAllFiveRenderersProduceIdenticalToolbarOutput(): void + { + $results = []; + foreach (self::RENDERER_CLASSES as $class) { + $element = $this->makeElement(); + $renderer = new $class(); + $code = $this->callProtected($renderer, 'renderFormDhtmlTAXoopsCode', [$element]); + $typography = $this->callProtected($renderer, 'renderFormDhtmlTATypography', [$element]); + $results[$class] = $code . $typography; + } + + $expected = reset($results); + $this->assertNotSame('', $expected, 'Precondition: toolbar output must not be empty'); + $this->assertStringContainsString('xo-edtb-btn', $expected); + + foreach ($results as $class => $actual) { + $this->assertSame($expected, $actual, "{$class}'s toolbar output diverges from the others"); + } + } + + /** + * Prove genuine delegation, not coincidence: the per-renderer combined output must equal the + * shared toolbar's own rows built directly (renderCodeButtons + renderTypography + + * renderCheckLength), for every one of the five renderers. + */ + public function testEveryRendererDelegatesToTheSameSharedToolbarInstanceShape(): void + { + $toolbarElement = $this->makeElement(); + $toolbar = new XoopsDhtmlToolbar(); + $direct = $toolbar->renderCodeButtons($toolbarElement) + . $toolbar->renderTypography($toolbarElement) + . $toolbar->renderCheckLength($toolbarElement); + + foreach (self::RENDERER_CLASSES as $class) { + $element = $this->makeElement(); + $renderer = new $class(); + $code = $this->callProtected($renderer, 'renderFormDhtmlTAXoopsCode', [$element]); + $typography = $this->callProtected($renderer, 'renderFormDhtmlTATypography', [$element]); + $this->assertSame($direct, $code . $typography, "{$class} did not delegate to XoopsDhtmlToolbar"); + } + } + + // ========================================================================= + // Test 2 — the codeicon preload event contract + // ========================================================================= + + /** + * The `core.class.xoopsform.formdhtmltextarea.codeicon` event fires with the code row by + * reference, after the core buttons and TextSanitizer extensions, immediately before the code + * row is returned — so a listener's appended markup must be present, and at the tail, of the + * returned string. + */ + public function testCodeiconPreloadEventFiresByReferenceAtSameLogicalPoint(): void + { + $this->injectCodeiconHandler(); + + $element = $this->makeElement(); + $toolbar = new XoopsDhtmlToolbar(); + $code = $toolbar->renderCodeButtons($element); + + $this->assertTrue(ToolbarCodeiconTestPreload::$called, 'codeicon preload handler was not invoked'); + $this->assertStringContainsString('data-testid="thirdparty-codeicon"', $code); + $this->assertStringEndsWith('', $code); + } + + // ========================================================================= + // Test 3 — TextSanitizer extension contract + // ========================================================================= + + /** + * Extension-supplied 'btn btn-default btn-sm' markup is rewritten to the toolbar's neutral + * classes, and any $js an extension returns lands on $element->js. Exercised end to end via + * the bundled 'youtube' extension, which is enabled in the shipped textsanitizer config and + * hardcodes those Bootstrap classes in its own encode() output. + */ + public function testExtensionButtonClassesAreRewrittenAndJsLandsOnElement(): void + { + $element = $this->makeElement(); + $toolbar = new XoopsDhtmlToolbar(); + $code = $toolbar->renderCodeButtons($element); + + $this->assertStringNotContainsString('btn-default', $code); + $this->assertStringContainsString('xo-edtb-btn xo-edtb-btn-sm', $code); + $this->assertStringContainsString('xoopsCodeYoutube', $code); + $this->assertStringContainsString('function xoopsCodeYoutube', $element->js); + } + + /** + * Focused unit test of the rewrite rule itself, independent of which bundled extension + * happens to be enabled. + */ + public function testRewriteExtensionButtonClassesReplacesKnownBootstrapClasses(): void + { + $toolbar = new XoopsDhtmlToolbar(); + $input = ""; + + $output = $this->callProtected($toolbar, 'rewriteExtensionButtonClasses', [$input]); + + $this->assertStringNotContainsString('btn-default', $output); + $this->assertStringNotContainsString('btn-secondary', $output); + $this->assertStringContainsString("class='xo-edtb-btn xo-edtb-btn-sm'", $output); + $this->assertStringContainsString("class='xo-edtb-btn'>y", $output); + } + + // ========================================================================= + // Test 4 — no document.write, no eval( + // ========================================================================= + + public function testOutputContainsNoDocumentWriteOrEval(): void + { + $element = $this->makeElement(); + $toolbar = new XoopsDhtmlToolbar(); + $full = $toolbar->render($element); + + $this->assertStringNotContainsString('document.write', $full); + $this->assertStringNotContainsString('eval(', $full); + } + + // ========================================================================= + // Test 5 — $GLOBALS['formtextdhtml_sizes']/['formtextdhtml_fonts'] overrides + // ========================================================================= + + public function testGlobalSizeAndFontOverridesAreReflectedInOutput(): void + { + $GLOBALS['formtextdhtml_sizes'] = ['huge' => 'Huge Size']; + $GLOBALS['formtextdhtml_fonts'] = ['CustomFontXYZ']; + + $element = $this->makeElement(); + $toolbar = new XoopsDhtmlToolbar(); + $typography = $toolbar->renderTypography($element); + + $this->assertStringContainsString('Huge Size', $typography); + $this->assertStringContainsString('huge', $typography); + $this->assertStringContainsString('CustomFontXYZ', $typography); + } + + // ========================================================================= + // Test 6 — check-length button + // ========================================================================= + + public function testCheckLengthButtonCarriesConfiguredMaxlength(): void + { + $element = $this->makeElement(); + $element->configs = ['maxlength' => 12345]; + + $toolbar = new XoopsDhtmlToolbar(); + $checkLength = $toolbar->renderCheckLength($element); + + $this->assertStringContainsString('XoopsCheckLength', $checkLength); + $this->assertStringContainsString('12345', $checkLength); + $this->assertStringContainsString($element->getName(), $checkLength); + } + + public function testCheckLengthButtonDefaultsToZeroWhenConfigsNotSet(): void + { + $element = $this->makeElement(); + $toolbar = new XoopsDhtmlToolbar(); + + $checkLength = $toolbar->renderCheckLength($element); + + $this->assertStringContainsString('XoopsCheckLength', $checkLength); + $this->assertStringContainsString('"0"', $checkLength); + } + + // ========================================================================= + // Stylesheet injection — Part 3 contract + // ========================================================================= + + public function testStylesheetIsInjectedOnceAcrossMultipleEditorsOnOnePage(): void + { + $toolbar = new XoopsDhtmlToolbar(); + + $first = $toolbar->render($this->makeElement('editorOne')); + $second = $toolbar->render($this->makeElement('editorTwo')); + + $this->assertStringContainsString('toolbar.css', $first); + $this->assertStringNotContainsString('toolbar.css', $second); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private function makeElement(string $name = 'toolbarfield'): XoopsFormDhtmlTextArea + { + return new XoopsFormDhtmlTextArea('Caption', $name, 'value', 5, 50, 'xoopsHiddenText'); + } + + /** + * @param object $obj + * @param string $method + * @param array $args + * + * @return mixed + */ + private function callProtected(object $obj, string $method, array $args = []) + { + $ref = new ReflectionMethod($obj, $method); + $ref->setAccessible(true); + + return $ref->invokeArgs($obj, $args); + } + + private function resetStylesheetGuard(): void + { + $ref = new ReflectionClass(XoopsDhtmlToolbar::class); + $prop = $ref->getProperty('styleInjected'); + $prop->setAccessible(true); + $prop->setValue(null, false); + } + + private function injectCodeiconHandler(): void + { + $instance = XoopsPreload::getInstance(); + $ref = new ReflectionClass($instance); + $prop = $ref->getProperty('_events'); + $prop->setAccessible(true); + $events = $prop->getValue($instance); + + // Normalized form of 'core.class.xoopsform.formdhtmltextarea.codeicon': dots stripped, + // lowercased — see XoopsPreload::triggerEvent(). + $events['coreclassxoopsformformdhtmltextareacodeicon'][] = [ + 'class_name' => ToolbarCodeiconTestPreload::class, + 'method' => 'eventCodeiconHandler', + ]; + $prop->setValue($instance, $events); + } +} 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