xref: /plugin/deletepageguard/action.php (revision bacaf98395dfe5da6c87ae6ce4a6b1b35aa59a89)
1e39ccd63SJonny Dee<?php
2e39ccd63SJonny Dee/**
3994617c9SJohann Duscher * Delete Page Guard for DokuWiki
4e39ccd63SJonny Dee *
5e39ccd63SJonny Dee * This action plugin prevents the deletion of pages by blocking "empty save"
6e39ccd63SJonny Dee * operations on pages whose IDs or file paths match a set of user‑defined
7e39ccd63SJonny Dee * regular expressions. Administrators (superusers) and optionally configured
8e39ccd63SJonny Dee * exempt groups are allowed to delete pages regardless of these patterns.
9e39ccd63SJonny Dee *
100da69785SJohann Duscher * @license GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html) - see LICENSE.md
11994617c9SJohann Duscher * @author  Johann Duscher <jonny.dee@posteo.net>
120da69785SJohann Duscher * @copyright 2025 Johann Duscher
13e39ccd63SJonny Dee */
14e39ccd63SJonny Dee
15e39ccd63SJonny Deeuse dokuwiki\Extension\ActionPlugin;
16e39ccd63SJonny Deeuse dokuwiki\Extension\Event;
17e39ccd63SJonny Deeuse dokuwiki\Extension\EventHandler;
18e39ccd63SJonny Dee
19e39ccd63SJonny Dee// Protect against direct call
20e39ccd63SJonny Deeif (!defined('DOKU_INC')) die();
21e39ccd63SJonny Dee
22e39ccd63SJonny Dee/**
23994617c9SJohann Duscher * Class action_plugin_deletepageguard
24e39ccd63SJonny Dee *
25e39ccd63SJonny Dee * Registers a handler on COMMON_WIKIPAGE_SAVE to intercept page save
26e39ccd63SJonny Dee * operations. When a deletion (empty save) is attempted on a protected page
27e39ccd63SJonny Dee * by a non‑admin user, the save is prevented and an error message is shown.
28e39ccd63SJonny Dee */
29994617c9SJohann Duscherclass action_plugin_deletepageguard extends ActionPlugin {
30e39ccd63SJonny Dee
31e39ccd63SJonny Dee    /**
32e39ccd63SJonny Dee     * Register the plugin events
33e39ccd63SJonny Dee     *
34e39ccd63SJonny Dee     * @param EventHandler $controller
35e39ccd63SJonny Dee     * @return void
36e39ccd63SJonny Dee     */
37e39ccd63SJonny Dee    public function register(EventHandler $controller) {
38e39ccd63SJonny Dee        // Run before the page is saved so we can abort the delete
39e39ccd63SJonny Dee        $controller->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $this, 'handle_common_wikipage_save');
40e39ccd63SJonny Dee    }
41e39ccd63SJonny Dee
42e39ccd63SJonny Dee    /**
43e39ccd63SJonny Dee     * Handler for the COMMON_WIKIPAGE_SAVE event
44e39ccd63SJonny Dee     *
45e39ccd63SJonny Dee     * This method checks whether the save operation represents a deletion
46e39ccd63SJonny Dee     * (i.e. the new content is empty) and whether the page matches one of
47e39ccd63SJonny Dee     * the configured regular expressions. If so, and the current user is
48e39ccd63SJonny Dee     * neither an administrator nor in one of the exempt groups, the
49e39ccd63SJonny Dee     * deletion is prevented.
50e39ccd63SJonny Dee     *
51e39ccd63SJonny Dee     * @param Event $event The event object
52e39ccd63SJonny Dee     * @param mixed $param Additional parameters (unused)
53e39ccd63SJonny Dee     * @return void
54e39ccd63SJonny Dee     */
55e39ccd63SJonny Dee    public function handle_common_wikipage_save(Event $event, $param) {
56e39ccd63SJonny Dee        global $USERINFO, $conf;
57e39ccd63SJonny Dee
58e39ccd63SJonny Dee        // Only take action when the event is preventable
59e39ccd63SJonny Dee        if (!$event->canPreventDefault) {
60e39ccd63SJonny Dee            return;
61e39ccd63SJonny Dee        }
62e39ccd63SJonny Dee
63e39ccd63SJonny Dee        // Allow administrators to delete pages
64e39ccd63SJonny Dee        if (function_exists('auth_isadmin') && auth_isadmin()) {
65e39ccd63SJonny Dee            return;
66e39ccd63SJonny Dee        }
67e39ccd63SJonny Dee
68e39ccd63SJonny Dee        // Check for exempt groups configuration
69e39ccd63SJonny Dee        $exemptSetting = (string)$this->getConf('exempt_groups');
70e39ccd63SJonny Dee        $exemptGroups  = array_filter(array_map('trim', explode(',', $exemptSetting)));
71e39ccd63SJonny Dee
72e39ccd63SJonny Dee        if (!empty($exemptGroups) && isset($USERINFO['grps']) && is_array($USERINFO['grps'])) {
73e39ccd63SJonny Dee            foreach ($USERINFO['grps'] as $group) {
74e39ccd63SJonny Dee                if (in_array($group, $exemptGroups, true)) {
75e39ccd63SJonny Dee                    // User is in an exempt group, allow deletion
76e39ccd63SJonny Dee                    return;
77e39ccd63SJonny Dee                }
78e39ccd63SJonny Dee            }
79e39ccd63SJonny Dee        }
80e39ccd63SJonny Dee
81e39ccd63SJonny Dee        // Determine if the save represents a deletion
82e39ccd63SJonny Dee        $newContent = isset($event->data['newContent']) ? $event->data['newContent'] : '';
83e39ccd63SJonny Dee        $trimMode   = (bool)$this->getConf('trim_mode');
84e39ccd63SJonny Dee        $isEmpty    = $trimMode ? trim($newContent) === '' : $newContent === '';
85e39ccd63SJonny Dee
86e39ccd63SJonny Dee        if (!$isEmpty) {
87e39ccd63SJonny Dee            // Not empty – normal edit, allow saving
88e39ccd63SJonny Dee            return;
89e39ccd63SJonny Dee        }
90e39ccd63SJonny Dee
91e39ccd63SJonny Dee        // Determine the matching target: page ID or relative file path
92e39ccd63SJonny Dee        $matchTarget = $this->getConf('match_target') === 'filepath' ?
93e39ccd63SJonny Dee            $this->getRelativeFilePath($event->data['file'], $conf['datadir']) :
94e39ccd63SJonny Dee            $event->data['id'];
95e39ccd63SJonny Dee
96e39ccd63SJonny Dee        // Retrieve regex patterns from configuration
97e39ccd63SJonny Dee        $patternsSetting = (string)$this->getConf('patterns');
98e39ccd63SJonny Dee        $patternLines    = preg_split('/\R+/', $patternsSetting, -1, PREG_SPLIT_NO_EMPTY);
99e39ccd63SJonny Dee
1001a97af9eSJohann Duscher        $hasValidationErrors = false;
1011a97af9eSJohann Duscher        foreach ($patternLines as $lineNumber => $rawPattern) {
102e39ccd63SJonny Dee            $pattern = trim($rawPattern);
103e39ccd63SJonny Dee            if ($pattern === '') {
104e39ccd63SJonny Dee                continue;
105e39ccd63SJonny Dee            }
1060da69785SJohann Duscher
1070da69785SJohann Duscher            // Validate and secure the regex pattern
1081a97af9eSJohann Duscher            $validationResult = $this->validateRegexPattern($pattern, $lineNumber + 1);
1091a97af9eSJohann Duscher            if ($validationResult !== true) {
1101a97af9eSJohann Duscher                // Show validation error to administrators
1111a97af9eSJohann Duscher                if (function_exists('auth_isadmin') && auth_isadmin()) {
1121a97af9eSJohann Duscher                    msg($validationResult, 2); // Warning level
1131a97af9eSJohann Duscher                }
1141a97af9eSJohann Duscher                $hasValidationErrors = true;
115e39ccd63SJonny Dee                continue;
116e39ccd63SJonny Dee            }
1170da69785SJohann Duscher
1180da69785SJohann Duscher            // Apply the regex with timeout protection
1190da69785SJohann Duscher            if ($this->matchesPattern($pattern, $matchTarget)) {
120e39ccd63SJonny Dee                // Match found – prevent deletion
121e39ccd63SJonny Dee                $event->preventDefault();
122e39ccd63SJonny Dee                $event->stopPropagation();
123e39ccd63SJonny Dee                msg($this->getLang('deny_msg'), -1);
124e39ccd63SJonny Dee                return;
125e39ccd63SJonny Dee            }
126e39ccd63SJonny Dee        }
1271a97af9eSJohann Duscher
1281a97af9eSJohann Duscher        // If there were validation errors, show a summary message to admins
1291a97af9eSJohann Duscher        if ($hasValidationErrors && function_exists('auth_isadmin') && auth_isadmin()) {
1301a97af9eSJohann Duscher            msg($this->getLang('config_validation_errors'), 2);
1311a97af9eSJohann Duscher        }
132e39ccd63SJonny Dee    }
133e39ccd63SJonny Dee
134e39ccd63SJonny Dee    /**
135e39ccd63SJonny Dee     * Convert an absolute file path into a relative one below the data directory
136e39ccd63SJonny Dee     *
137e39ccd63SJonny Dee     * The COMMON_WIKIPAGE_SAVE event provides the absolute file path. When
138e39ccd63SJonny Dee     * matching against the file path, we want a path relative to the base
139e39ccd63SJonny Dee     * pages directory so users can write concise regular expressions.
140e39ccd63SJonny Dee     *
141e39ccd63SJonny Dee     * @param string $fullPath Absolute path to the file
142e39ccd63SJonny Dee     * @param string $dataDir  Base data directory (usually $conf['datadir'])
143e39ccd63SJonny Dee     * @return string Relative file path
144e39ccd63SJonny Dee     */
145e39ccd63SJonny Dee    protected function getRelativeFilePath($fullPath, $dataDir) {
146bbe7181bSJohann Duscher        // Normalize path separators to forward slashes for consistency
147bbe7181bSJohann Duscher        $fullPath = str_replace('\\', '/', $fullPath);
148bbe7181bSJohann Duscher        $dataDir = str_replace('\\', '/', $dataDir);
149bbe7181bSJohann Duscher
150e39ccd63SJonny Dee        $base = rtrim($dataDir, '/');
151e39ccd63SJonny Dee        // DokuWiki stores pages in $datadir/pages
152e39ccd63SJonny Dee        $pagesDir = $base . '/pages/';
153e39ccd63SJonny Dee        if (strpos($fullPath, $pagesDir) === 0) {
154e39ccd63SJonny Dee            return substr($fullPath, strlen($pagesDir));
155e39ccd63SJonny Dee        }
156e39ccd63SJonny Dee        // Fallback: attempt to strip base datadir
157e39ccd63SJonny Dee        if (strpos($fullPath, $base . '/') === 0) {
158e39ccd63SJonny Dee            return substr($fullPath, strlen($base) + 1);
159e39ccd63SJonny Dee        }
160e39ccd63SJonny Dee        return $fullPath;
161e39ccd63SJonny Dee    }
1620da69785SJohann Duscher
1630da69785SJohann Duscher    /**
164*bacaf983SJohann Duscher     * Get the match target (page ID or file path) for a given page ID
165*bacaf983SJohann Duscher     *
166*bacaf983SJohann Duscher     * This is a public helper method for the admin plugin to convert page IDs
167*bacaf983SJohann Duscher     * to the appropriate match target based on configuration.
168*bacaf983SJohann Duscher     *
169*bacaf983SJohann Duscher     * @param string $pageId The DokuWiki page ID
170*bacaf983SJohann Duscher     * @return string The match target (either page ID or relative file path)
171*bacaf983SJohann Duscher     */
172*bacaf983SJohann Duscher    public function getMatchTarget($pageId) {
173*bacaf983SJohann Duscher        global $conf;
174*bacaf983SJohann Duscher
175*bacaf983SJohann Duscher        if ($this->getConf('match_target') === 'filepath') {
176*bacaf983SJohann Duscher            // Convert page ID to file path
177*bacaf983SJohann Duscher            $filePath = wikiFN($pageId);
178*bacaf983SJohann Duscher            return $this->getRelativeFilePath($filePath, $conf['datadir']);
179*bacaf983SJohann Duscher        }
180*bacaf983SJohann Duscher
181*bacaf983SJohann Duscher        return $pageId;
182*bacaf983SJohann Duscher    }
183*bacaf983SJohann Duscher
184*bacaf983SJohann Duscher    /**
1859a383d51SJohann Duscher     * Validate a regex pattern for security and correctness
1860da69785SJohann Duscher     *
1870da69785SJohann Duscher     * Performs basic validation to prevent ReDoS attacks and ensure the
1881a97af9eSJohann Duscher     * pattern is syntactically correct. Returns detailed error messages.
1899a383d51SJohann Duscher     * This method is public to allow the admin plugin to reuse validation logic.
1900da69785SJohann Duscher     *
1910da69785SJohann Duscher     * @param string $pattern The regex pattern to validate
1921a97af9eSJohann Duscher     * @param int $lineNumber The line number for error reporting
1931a97af9eSJohann Duscher     * @return string|true True if valid, error message string if invalid
1940da69785SJohann Duscher     */
1959a383d51SJohann Duscher    public function validateRegexPattern($pattern, $lineNumber = 0) {
1961a97af9eSJohann Duscher        $linePrefix = $lineNumber > 0 ? "Line $lineNumber: " : "";
1971a97af9eSJohann Duscher
198bbe7181bSJohann Duscher        // Empty patterns are not valid
199bbe7181bSJohann Duscher        if (trim($pattern) === '') {
200bbe7181bSJohann Duscher            return $linePrefix . 'Empty pattern is not allowed';
201bbe7181bSJohann Duscher        }
202bbe7181bSJohann Duscher
2030da69785SJohann Duscher        // Check for obviously malicious patterns (basic ReDoS protection)
204bbe7181bSJohann Duscher        // Detect patterns like (a+)+, (x+)*, (a+)+b, etc.
2059a383d51SJohann Duscher        // Look for nested quantifiers: (anything with + or *) followed by + or *
2069a383d51SJohann Duscher        if (preg_match('/\([^)]*[\+\*][^)]*\)[\+\*]/', $pattern) ||
2079a383d51SJohann Duscher            preg_match('/\(.+[\+\*]\)[\+\*]/', $pattern)) {
2081a97af9eSJohann Duscher            return $linePrefix . sprintf($this->getLang('pattern_redos_warning'), $pattern);
2090da69785SJohann Duscher        }
2100da69785SJohann Duscher
2110da69785SJohann Duscher        // Limit pattern length to prevent extremely complex patterns
2120da69785SJohann Duscher        if (strlen($pattern) > 1000) {
2131a97af9eSJohann Duscher            return $linePrefix . sprintf($this->getLang('pattern_too_long'), $pattern);
2140da69785SJohann Duscher        }
2150da69785SJohann Duscher
2160da69785SJohann Duscher        // Test if the pattern is syntactically valid
2171a97af9eSJohann Duscher        $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u';
2181a97af9eSJohann Duscher        $test = @preg_match($escapedPattern, '');
2190da69785SJohann Duscher        if ($test === false) {
2201a97af9eSJohann Duscher            $error = error_get_last();
2211a97af9eSJohann Duscher            $errorMsg = $error && isset($error['message']) ? $error['message'] : 'Unknown regex error';
2221a97af9eSJohann Duscher            return $linePrefix . sprintf($this->getLang('pattern_invalid_syntax'), $pattern, $errorMsg);
2230da69785SJohann Duscher        }
2240da69785SJohann Duscher
2250da69785SJohann Duscher        return true;
2260da69785SJohann Duscher    }
2270da69785SJohann Duscher
2280da69785SJohann Duscher    /**
229*bacaf983SJohann Duscher     * Check if a pattern matches a target string
2300da69785SJohann Duscher     *
2310da69785SJohann Duscher     * Applies the regex pattern with error handling and basic timeout protection
232*bacaf983SJohann Duscher     * to prevent ReDoS attacks. This method is public to allow the admin plugin
233*bacaf983SJohann Duscher     * to test patterns against page lists.
2340da69785SJohann Duscher     *
2350da69785SJohann Duscher     * @param string $pattern The validated regex pattern
2360da69785SJohann Duscher     * @param string $target  The string to match against
2370da69785SJohann Duscher     * @return bool True if the pattern matches, false otherwise
2380da69785SJohann Duscher     */
239*bacaf983SJohann Duscher    public function matchesPattern($pattern, $target) {
2400da69785SJohann Duscher        // Escape forward slashes in pattern to use with / delimiters
2410da69785SJohann Duscher        $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u';
2420da69785SJohann Duscher
2430da69785SJohann Duscher        // Set a reasonable time limit for regex execution (basic ReDoS protection)
2440da69785SJohann Duscher        $oldTimeLimit = ini_get('max_execution_time');
2450da69785SJohann Duscher        if ($oldTimeLimit > 5) {
2460da69785SJohann Duscher            @set_time_limit(5);
2470da69785SJohann Duscher        }
2480da69785SJohann Duscher
2490da69785SJohann Duscher        $result = @preg_match($escapedPattern, $target);
2500da69785SJohann Duscher
2510da69785SJohann Duscher        // Restore original time limit
2520da69785SJohann Duscher        if ($oldTimeLimit > 5) {
2530da69785SJohann Duscher            @set_time_limit($oldTimeLimit);
2540da69785SJohann Duscher        }
2550da69785SJohann Duscher
2560da69785SJohann Duscher        return $result === 1;
2570da69785SJohann Duscher    }
258e39ccd63SJonny Dee}
259