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