1<?php
2
3use dokuwiki\Extension\ActionPlugin;
4use dokuwiki\Extension\EventHandler;
5use dokuwiki\Extension\Event;
6
7/**
8 * DokuWiki Plugin aclplusregex (Action Component)
9 *
10 * Here is how it works:
11 *
12 * 1) load the configuration
13 * 2) for each line apply the user/group regex to the users login and groups
14 *    this also resolves backreferences in the ID part of the line
15 * 3) for all matched lines where the resolved ID part is exactly the same, keep only the
16 *    one with the maximum permissions
17 * 4) sort the remaining lines in a way that the most significant IDs come first, with all
18 *    placeholders having the lowest priority
19 * 5) transform placeholders in their regex equivalents and put each into a named regex
20 *    group that has the resulting permission in it's name
21 * 6) combine all groups in a single regular expression (most significant is first)
22 * 7) cache result of step 1 to 7 in the singleton class
23 * 8) apply the regex on the current ID
24 * 9) if it matches, check which named group has the match and extract the permission from it
25 * 10a) in BEFORE mode use that permission and stop processing
26 * 10b) in AFTER mode apply the permission if it's higher than what DokuWiki decided
27 */
28class action_plugin_aclplusregex extends ActionPlugin
29{
30    public const CONFFILE = DOKU_CONF . 'aclplusregex.conf';
31
32    /** @var string Regex for the * placeholder */
33    public const STAR = '[^:]+';
34    /** @var string Regex for the ** placeholder */
35    public const STARS = '[^:]+(:[^:]+)*';
36
37    /** @var array we store the regexes per user here */
38    protected $ruleCache = [];
39
40    /** @var int used to uniquely name capture groups */
41    protected $counter = 0;
42
43    /**
44     * Registers a callback function for a given event
45     *
46     * @param EventHandler $controller DokuWiki's event controller object
47     * @return void
48     */
49    public function register(EventHandler $controller)
50    {
51        $mode = $this->getConf('run');
52        $controller->register_hook('AUTH_ACL_CHECK', $mode, $this, 'handleAcl', $mode);
53    }
54
55    /**
56     * Apply our own acl checking mechanism
57     *
58     * @param Event $event event object by reference
59     * @param string $mode BEFORE|AFTER
60     * @return void
61     */
62    public function handleAcl(Event $event, $mode)
63    {
64        $id = $event->data['id'];
65        $user = $event->data['user'];
66        $groups = $event->data['groups'];
67
68        if ($user === '') return;
69        if (auth_isadmin($user)) return;
70
71        // use cached user rules or fetch new ones if not available
72        if (!isset($this->ruleCache[$user])) {
73            $this->ruleCache[$user] = $this->rulesToRegex($this->loadACLRules($user, $groups));
74        }
75
76        // apply the rules and use the resulting permission
77        $previous = $event->result ?: AUTH_NONE;
78        $permisson = $this->evaluateRegex($this->ruleCache[$user], $id);
79        if ($permisson !== false) {
80            $event->result = max($previous, $permisson);
81
82            // in BEFORE mode also prevent additional checks
83            if ($mode === 'BEFORE') {
84                $event->preventDefault();
85            }
86        }
87    }
88
89    /**
90     * Applies the given regular expression to the ID and returns the resulting permission
91     *
92     * Important: there's a difference between a return value of 0 = AUTH_NONE and false = no match found
93     *
94     * @param string $regex
95     * @param string $id
96     * @return false|int
97     */
98    protected function evaluateRegex($regex, $id)
99    {
100        if (!preg_match($regex, $id, $matches)) {
101            // no rule matches
102            return false;
103        }
104
105        // now figure out which group matched
106        foreach ($matches as $key => $match) {
107            if (!is_string($key)) continue; // we only care bout named groups
108            if ($match === '') continue; // this one didn't match
109
110            [, $perm] = explode('x', $key); // the part after the x is our permission
111            return (int)$perm;
112        }
113
114        return false; //shouldn't never be reached
115    }
116
117    /**
118     * Load the custom ACL regexes for the given user
119     *
120     * @param string $user
121     * @param string[] $groups
122     * @return array
123     */
124    protected function loadACLRules($user, $groups)
125    {
126        $entities = $this->createUserGroupEntities($user, $groups);
127        $config = $this->getConfiguration();
128
129        // get all rules that apply to the user and their groups
130        $rules = [];
131        foreach ($config as [$id, $pattern, $perm]) {
132            $perm = (int)$perm;
133            $patterns = $this->getIDPatterns($entities, $id, $pattern);
134            foreach ($patterns as $pattern) {
135                // for the exactly same pattern, we only keep the highest permission
136                $rules[$pattern] = max($rules[$pattern] ?? AUTH_NONE, $perm);
137            }
138        }
139
140        // sort rules by significance
141        $rules = $this->sortRules($rules);
142
143        return $rules;
144    }
145
146    /**
147     * Convert the list of rules to a single regular expression
148     *
149     * @param array $rules
150     * @return string
151     */
152    protected function rulesToRegex($rules)
153    {
154        $reGroup = [];
155        foreach ($rules as $rule => $perm) {
156            $reGroup[] = $this->patternToRegexGroup($rule, $perm);
157        }
158
159        return '/^(' . implode('|', $reGroup) . ')$/';
160    }
161
162    /**
163     * Combines the user and group info in prefixed entities
164     *
165     * @param string $user
166     * @param string[] $groups
167     * @return array
168     */
169    protected function createUserGroupEntities($user, $groups)
170    {
171        array_walk($groups, function (&$gr) {
172            $gr = '@' . $gr;
173        });
174        $entities = (array)$groups;
175        $entities[] = $user;
176        $entities[] = '@ALL'; // everyone is in this
177        return $entities;
178    }
179
180    /**
181     * Returns all ID patterns that match the given user/group entities
182     *
183     * @param string[] $entities List of username and groups
184     * @param string $id The pageID part of the config rule
185     * @param string $pattern The user pattern part of the config rule
186     * @return string[]
187     */
188    protected function getIDPatterns($entities, $id, $pattern)
189    {
190        $result = [];
191
192        foreach ($entities as $entity) {
193            $check = "$id\n$entity";
194            $cnt = 0;
195
196            // pattern not starting with @ should only match users
197            if ($pattern[0] !== '@') {
198                $pattern = '(?!@)' . $pattern;
199            }
200
201            // this does a match on the pattern and replaces backreferences at the same time
202            $match = preg_replace("/^$pattern$/m", $check, $entity, 1, $cnt);
203            if ($cnt > 0) {
204                $result[] = $this->cleanID(explode("\n", $match)[0]);
205            }
206        }
207
208        return $result;
209    }
210
211    /**
212     * Replaces * and ** in IDs with their proper regex equivalents and returns a named
213     * group which's name encodes the permission
214     *
215     * @param string $idpattern
216     * @param int $perm
217     * @return string
218     */
219    protected function patternToRegexGroup($idpattern, $perm)
220    {
221        $idpattern = strtr(
222            $idpattern,
223            [
224                '**' => self::STARS,
225                '*' => self::STAR,
226            ]
227        );
228
229        // we abuse named groups to know the for the rule later
230        $name = 'g' . ($this->counter++) . 'x' . $perm;
231
232        return '(?<' . $name . '>' . $idpattern . ')';
233    }
234
235    /**
236     * @return string[][] a list of (id, pattern, perm)
237     */
238    protected function getConfiguration()
239    {
240        if (!is_file(static::CONFFILE)) {
241            return [];
242        }
243
244        $config = [];
245        $file = file(static::CONFFILE);
246        foreach ($file as $line) {
247            $line = preg_replace('/#.*$/', '', $line); // strip comments
248            $line = trim($line);
249            if ($line === '') continue;
250            $config[] = array_map('rawurldecode', preg_split('/[ \t]+/', $line, 3)); // config is encoded
251        }
252
253        return $config;
254    }
255
256    /**
257     * Sort the given rules so that the most significant ones come first
258     *
259     * @param array $rules
260     * @return array (rule => perm)
261     */
262    protected function sortRules($rules)
263    {
264        uksort($rules, function ($a, $b) {
265            $partsA = explode(':', $a);
266            $countA = count($partsA);
267            $partsB = explode(':', $b);
268            $countB = count($partsB);
269
270            for ($i = 0; $i < max($countA, $countB); $i++) {
271                // fill up missing parts with low prio markers
272                $partA = $partsA[$i] ?? '**';
273                $partB = $partsB[$i] ?? '**';
274
275                // if both parts are the same, move on
276                if ($partA === $partB) continue;
277
278                // greedy placeholders go last
279                if ($partA === '**') return 1;
280                if ($partB === '**') return -1;
281
282                // nongreedy placeholders go second last
283                if ($partA === '*') return 1;
284                if ($partB === '*') return -1;
285
286                // regex goes after simple strings
287                if ($this->containsRegex($partA) && !$this->containsRegex($partB)) return 1;
288                if ($this->containsRegex($partB) && !$this->containsRegex($partA)) return -1;
289
290                // just compare alphabetically
291                return strcmp($a, $b);
292            }
293
294            // probably never reached
295            return strcmp($a, $b);
296        });
297
298        return $rules;
299    }
300
301    /**
302     * Applies cleanID to each separate part of the ID
303     *
304     * Keeps * and ** placeholders, as well as parts containing
305     * regular expressions
306     *
307     * @param string $id
308     * @return string
309     * @see \cleanID()
310     */
311    protected function cleanID($id)
312    {
313        $parts = explode(':', $id);
314        $count = count($parts);
315        for ($i = 0; $i < $count; $i++) {
316            if ($parts[$i] == '**') continue;
317            if ($parts[$i] == '*') continue;
318            if ($this->containsRegex($parts[$i])) continue;
319
320            $parts[$i] = cleanID($parts[$i]);
321            if ($parts[$i] === '') unset($parts[$i]);
322        }
323        return implode(':', $parts);
324    }
325
326    /**
327     * Detect if a string contains a regular expression
328     * by the presence of parentheses
329     *
330     * @param $part
331     * @return bool
332     */
333    protected function containsRegex($part)
334    {
335        return strpos($part, '(') !== false &&
336            strpos($part, ')') !== false;
337    }
338}
339