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