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