xref: /plugin/pagecss/action.php (revision 7d6669007238fef7e8728f167d637ed824899eb0)
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