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