1<?php 2/** 3 * DokuWiki Plugin: pagecss 4 * 5 * This plugin allows DokuWiki users to embed custom CSS directly within their 6 * wiki pages using `<pagecss>...</pagecss>` blocks. The CSS defined within 7 * these blocks is then extracted, processed, and injected into the `<head>` 8 * section of the generated HTML page. 9 * 10 * It also provides a feature to automatically wrap CSS rules for classes 11 * found within the `<pagecss>` block (e.g., `.myclass { ... }`) with a 12 * `.wrap_myclass { ... }` equivalent. This is useful for styling elements 13 * that are automatically wrapped by DokuWiki's `.wrap` classes. 14 * 15 * Author: dWiGhT Mulcahy 16 * Date: 2025-07-27 17 * 18 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 19 */ 20 21// Import necessary DokuWiki extension classes 22use dokuwiki\Extension\ActionPlugin; 23use dokuwiki\Extension\EventHandler; 24use dokuwiki\Extension\Event; 25 26// --- Tidy CSS Integration --- 27require_once __DIR__ . '/vendor/csstidy-2.2.1/class.csstidy.php'; 28// --- End Tidy CSS Integration --- 29 30/** 31 * Class action_plugin_pagecss 32 * 33 * This class extends DokuWiki's ActionPlugin to hook into specific DokuWiki 34 * events for processing and injecting custom page-specific CSS. 35 */ 36class action_plugin_pagecss extends ActionPlugin { 37 38 /** 39 * Registers the plugin's hooks with the DokuWiki event handler. 40 * 41 * This method is called by DokuWiki during plugin initialization. 42 * It sets up which DokuWiki events this plugin will listen for and 43 * which methods will handle those events. 44 * 45 * @param EventHandler $controller The DokuWiki event handler instance. 46 */ 47 public function register(EventHandler $controller) { 48 // Register a hook to inject custom CSS into the HTML header. 49 // 'TPL_METAHEADER_OUTPUT' is triggered just before the <head> section is closed. 50 // 'BEFORE' ensures our CSS is added before other elements that might rely on it. 51 // '$this' refers to the current plugin instance. 52 // 'inject_css' is the method that will be called when this event fires. 53 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'inject_css'); 54 55 // Register a hook to handle metadata caching and extraction of CSS. 56 // 'PARSER_CACHE_USE' is triggered before DokuWiki attempts to use its parser cache. 57 // 'BEFORE' allows us to modify the metadata before the page is rendered or cached. 58 // 'handle_metadata' is the method that will be called. 59 $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'handle_metadata'); 60 } 61 62 /** 63 * Sanitize user-provided CSS using CSSTidy and additional filtering. 64 * 65 * @param string $css_input Raw user CSS inside <pagecss> block 66 * @return string Cleaned, safe CSS or an empty string if invalid 67 */ 68 /** 69 * Sanitize user-provided CSS using CSSTidy and additional filtering 70 * to prevent CSS-based XSS and injection attacks. 71 * 72 * @param string $css Raw user CSS inside <pagecss> block 73 * @return string Cleaned, safe CSS or empty string if invalid or dangerous 74 */ 75 private function sanitizeCSS($css) { 76 dbglog("pagecss: raw\n$css", 2); 77 78 // Bail if too many CSS blocks (basic sanity check) 79 if (substr_count($css, '{') > 100) { 80 dbglog("pagecss: too many CSS blocks", 2); 81 return ''; 82 } 83 84 // Initialize CSSTidy and configure for safe cleanup 85 $tidy = new csstidy(); 86 $tidy->set_cfg('remove_bslash', true); 87 $tidy->set_cfg('compress_colors', true); 88 $tidy->set_cfg('compress_font-weight', true); 89 $tidy->set_cfg('lowercase_s', true); 90 $tidy->set_cfg('optimise_shorthands', 1); 91 $tidy->parse($css); 92 93 $tidy_css = $tidy->print->plain(); 94 dbglog("pagecss: tidy\n$tidy_css", 2); 95 96 // Bail if output is suspiciously long 97 if (strlen($tidy_css) > 5000) { 98 dbglog("pagecss: too long after tidy", 2); 99 return ''; 100 } 101 102 // Further harden CSS by blocking dangerous patterns: 103 $patterns = [ 104 '/expression\s*\(.*?\)/i', // - CSS expressions (IE-only) 105 '/url\s*\(\s*[\'"]?\s*javascript:/i', // - javascript: URLs in url() 106 '/behavior\s*:/i', // - behavior property (IE) 107 '/-moz-binding\s*:/i', // - -moz-binding property (Firefox) 108 '/url\s*\(\s*[\'"]?\s*data:text\/html/i', // - data:text/html URLs (potential script injection) 109 '/@import/i', // - @import rules 110 '/unicode-range/i', // - unicode-range declarations (can hide obfuscated code) 111 ]; 112 113 foreach ($patterns as $pattern) { 114 if (preg_match($pattern, $tidy_css)) { 115 dbglog("pagecss: blocked dangerous CSS pattern: $pattern", 2); 116 return ''; // Reject entire CSS block if dangerous pattern found 117 } 118 } 119 120 // Remove control characters which may cause issues 121 $tidy_css = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $tidy_css); 122 123 dbglog("pagecss: sanitized\n$tidy_css", 2); 124 return $tidy_css; 125 } 126 127 /** 128 * Extracts CSS content from `<pagecss>...</pagecss>` blocks within a DokuWiki page. 129 * 130 * This method is triggered by the 'PARSER_CACHE_USE' event. It reads the raw 131 * content of the current wiki page, finds all `<pagecss>` blocks, and combines 132 * their content, and stores it in the page's metadata. It also generates 133 * `.wrap_classname` rules for any classes found in the embedded CSS. 134 * 135 * @param \Doku_Event $event The DokuWiki event object, containing page data. 136 */ 137 public function handle_metadata(\Doku_Event $event) { 138 global $ID; 139 140 $id = cleanID($ID); 141 $text = rawWiki($id); 142 143 // Sanitize the page ID to ensure it's safe for file system operations and metadata. 144 $id = cleanID($ID); 145 // Get the raw content of the current wiki page. This includes all wiki syntax. 146 $text = rawWiki($id); 147 148 // Use a regular expression to find all occurrences of <pagecss>...</pagecss> blocks. 149 // The /s modifier makes the dot (.) match newlines as well, allowing multiline CSS. 150 // The (.*?) captures the content between the tags non-greedily. 151 preg_match_all('/<pagecss>(.*?)<\/pagecss>/s', $text, $matches); 152 153 if (!empty($matches[1])) { 154 $styles_raw = implode(" ", array_map('trim', $matches[1])); 155 156 if ($styles_raw) { 157 $sanitized_css = $this->sanitizeCSS($styles_raw); 158 159 if (!$sanitized_css) { 160 dbglog("pagecss: CSS sanitized to empty for $ID", 2); 161 p_set_metadata($id, ['pagecss' => ['styles' => '']]); 162 return; 163 } 164 165 dbglog("pagecss: sanitized CSS ready for $ID", 2); 166 167 $extra = ''; 168 preg_match_all('/\.([a-zA-Z0-9_-]+)\s*\{[^}]*\}/', $sanitized_css, $class_matches); 169 170 if (!empty($class_matches[1])) { 171 dbglog("pagecss: found class selectors: " . implode(', ', $class_matches[1]), 2); 172 } 173 174 foreach ($class_matches[1] as $classname) { 175 // Construct a regex pattern to find the full CSS rule for the current class. 176 $pattern = '/\.' . preg_quote($classname, '/') . '\s*\{([^}]*)\}/'; 177 if (preg_match($pattern, $sanitized_css, $style_block)) { 178 $css_properties = $style_block[1]; 179 if (strpos($css_properties, '{') === false && strpos($css_properties, '}') === false) { 180 $extra .= ".wrap_$classname {{$css_properties}}\n"; 181 } 182 } 183 } 184 185 $styles = $sanitized_css . "\n" . trim($extra); 186 $styles = str_replace('</', '<\/', $styles); 187 188 p_set_metadata($id, ['pagecss' => ['styles' => $styles]]); 189 dbglog("pagecss: styles stored in metadata for $id", 2); 190 return; 191 } 192 } 193 194 // Clear styles if none found 195 p_set_metadata($id, ['pagecss' => ['styles' => '']]); 196 dbglog("pagecss: no <pagecss> blocks found, clearing styles for $id", 2); 197 } 198 199 /** 200 * Injects the extracted CSS into the HTML `<head>` section of the DokuWiki page. 201 * 202 * This method is triggered by the 'TPL_METAHEADER_OUTPUT' event. It retrieves 203 * the stored CSS from the page's metadata and adds it to the event data, 204 * which DokuWiki then uses to build the `<head>` section. 205 * 206 * @param Doku_Event $event The DokuWiki event object, specifically for metaheader output. 207 */ 208 public function inject_css(Doku_Event $event) { 209 global $ID; // Global variable holding the current DokuWiki page ID. 210 211 // Sanitize the page ID. 212 $id = cleanID($ID); 213 214 // Retrieve the 'pagecss' metadata for the current page. 215 $data = p_get_metadata($id, 'pagecss'); 216 // Extract the 'styles' content from the metadata, defaulting to an empty string if not set. 217 $styles = isset($data['styles']) ? $data['styles'] : ''; 218 219 // Check if there are valid styles to inject and ensure it's a string. 220 if ($styles && is_string($styles)) { 221 // Add the custom CSS to the event's 'style' array. 222 // DokuWiki's template system will then automatically render this 223 // as a <style> block within the HTML <head>. 224 $event->data['style'][] = [ 225 'type' => 'text/css', // Specifies the content type. 226 'media' => 'screen', // Specifies the media type for the CSS (e.g., 'screen', 'print'). 227 '_data' => $styles, // The actual CSS content. 228 ]; 229 } 230 } 231 232} 233