| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 11: | |
| 12: | |
| 13: | |
| 14: | |
| 15: | |
| 16: | |
| 17: | |
| 18: | |
| 19: | |
| 20: | |
| 21: | use Module\Support\Personality\Helpers\CacheablePriorityHeap; |
| 22: | use Tivie\HtaccessParser\HtaccessContainer; |
| 23: | use Tivie\HtaccessParser\Token\BaseToken; |
| 24: | |
| 25: | class Personality_Module extends Module_Skeleton |
| 26: | { |
| 27: | const CONTROL_FILE = '.htaccess'; |
| 28: | const CACHE_KEY_PREFIX = 'pnlty:'; |
| 29: | |
| 30: | const PERSONALITY_MODULE_LOCATION = 'lib/Module/Support/Personality'; |
| 31: | |
| 32: | public $exportedFunctions = array( |
| 33: | '*' => PRIVILEGE_SITE |
| 34: | ); |
| 35: | |
| 36: | |
| 37: | private $_personalities = array(); |
| 38: | private $_resolverCache = array(); |
| 39: | private $_instances = array(); |
| 40: | |
| 41: | public function __wakeup() |
| 42: | { |
| 43: | $this->_personalities = array(); |
| 44: | $this->_instances = array(); |
| 45: | if (is_debug()) { |
| 46: | $this->_resolverCache = array(); |
| 47: | } |
| 48: | } |
| 49: | |
| 50: | |
| 51: | |
| 52: | |
| 53: | |
| 54: | |
| 55: | public function scan($file) |
| 56: | { |
| 57: | if (!$this->file_exists($file)) { |
| 58: | $this->file_touch($file); |
| 59: | } |
| 60: | $splfile = $this->_getSPLObjectFromFile($file); |
| 61: | if (!is_object($splfile)) { |
| 62: | return false; |
| 63: | } |
| 64: | if (false === ($parser = $this->_getCache($splfile))) { |
| 65: | $parser = $this->_getParser($splfile); |
| 66: | $this->_setCache($splfile, $parser); |
| 67: | |
| 68: | return $this->_parse($parser); |
| 69: | } |
| 70: | |
| 71: | return $parser; |
| 72: | } |
| 73: | |
| 74: | private function _getSPLObjectFromFile($file) |
| 75: | { |
| 76: | $path = $this->file_make_path($file); |
| 77: | try { |
| 78: | $splfile = new SplFileObject($path); |
| 79: | } catch (Exception $e) { |
| 80: | return error("unable to access control file `%s': %s", |
| 81: | $this->file_unmake_path($file), |
| 82: | $e->getMessage() |
| 83: | ); |
| 84: | } |
| 85: | |
| 86: | return $splfile; |
| 87: | |
| 88: | } |
| 89: | |
| 90: | private function _getCache(SplFileObject $file, $which = 'entry') |
| 91: | { |
| 92: | $cache = Cache_Account::spawn($this->getAuthContext()); |
| 93: | $key = self::CACHE_KEY_PREFIX . $file->getInode(); |
| 94: | if (false !== ($res = $cache->get($key))) { |
| 95: | if ($res['time'] == $file->getMTime()) { |
| 96: | return $res[$which]; |
| 97: | } |
| 98: | } |
| 99: | |
| 100: | return false; |
| 101: | } |
| 102: | |
| 103: | private function _getParser(SplFileObject $splfile): \Tivie\HtaccessParser\Parser |
| 104: | { |
| 105: | $parser = new \Tivie\HtaccessParser\Parser(); |
| 106: | $parser->ignoreComments(false)->ignoreWhitelines(false); |
| 107: | $parser->setFile($splfile); |
| 108: | |
| 109: | return $parser; |
| 110: | } |
| 111: | |
| 112: | |
| 113: | |
| 114: | |
| 115: | |
| 116: | |
| 117: | |
| 118: | |
| 119: | |
| 120: | private function _setCache(SplFileObject $file, \Tivie\HtaccessParser\Parser $parser) |
| 121: | { |
| 122: | $cache = Cache_Account::spawn($this->getAuthContext()); |
| 123: | $key = self::CACHE_KEY_PREFIX . $file->getInode(); |
| 124: | if (!($parsed = $this->_parse($parser, $file))) { |
| 125: | return $parsed; |
| 126: | } |
| 127: | |
| 128: | return $cache->set($key, array( |
| 129: | 'time' => $file->getMTime(), |
| 130: | 'hash' => $parsed->txtSerialize(), |
| 131: | 'entry' => $parsed |
| 132: | )); |
| 133: | } |
| 134: | |
| 135: | public function get_personalities() |
| 136: | { |
| 137: | $personalities = $this->_tryPersonalities(); |
| 138: | $ret = array(); |
| 139: | while (false !== ($personalities->valid())) { |
| 140: | $name = $personalities->current(); |
| 141: | $ret[] = $name; |
| 142: | $personalities->next(); |
| 143: | } |
| 144: | |
| 145: | return $ret; |
| 146: | } |
| 147: | |
| 148: | private function _tryPersonalities() |
| 149: | { |
| 150: | $key = 'prsntly'; |
| 151: | $cache = Cache_Global::spawn(); |
| 152: | $personalities = $cache->get($key); |
| 153: | if ($personalities) { |
| 154: | |
| 155: | $tmp = Util_PHP::unserialize($personalities, true); |
| 156: | $this->_personalities = $tmp['personalities']; |
| 157: | $this->_instances = $tmp['instances']; |
| 158: | |
| 159: | return $this->_personalities; |
| 160: | } |
| 161: | |
| 162: | |
| 163: | $queue = new CacheablePriorityHeap(); |
| 164: | |
| 165: | $dir = INCLUDE_PATH . '/' . self::PERSONALITY_MODULE_LOCATION; |
| 166: | $dh = opendir($dir); |
| 167: | if (!$dh) { |
| 168: | return error('unable to access personality module location'); |
| 169: | } |
| 170: | while (false !== ($entry = readdir($dh))) { |
| 171: | if ($entry[0] === '.') { |
| 172: | continue; |
| 173: | } |
| 174: | $pos = strrpos($entry, '.'); |
| 175: | if ($pos === false || substr($entry, $pos) !== '.php') { |
| 176: | continue; |
| 177: | } |
| 178: | |
| 179: | $type = substr($entry, 0, strpos($entry, '.')); |
| 180: | $class = $this->_getInstanceFromPersonality($type); |
| 181: | $queue->insert($type, $class->getPriority()); |
| 182: | } |
| 183: | |
| 184: | closedir($dh); |
| 185: | $queue->rewind(); |
| 186: | $this->_personalities = $queue; |
| 187: | |
| 188: | $data = array( |
| 189: | 'personalities' => $this->_personalities, |
| 190: | 'instances' => $this->_instances |
| 191: | ); |
| 192: | |
| 193: | $cache->set($key, serialize($data)); |
| 194: | |
| 195: | return $queue; |
| 196: | } |
| 197: | |
| 198: | private function _getInstanceFromPersonality($personality) |
| 199: | { |
| 200: | if (!$personality) { |
| 201: | return error('no personality specified'); |
| 202: | } |
| 203: | |
| 204: | if (isset($this->_instances[$personality])) { |
| 205: | return $this->_instances[$personality]; |
| 206: | } |
| 207: | $class = '\\Module\\Support\\Personality\\' . ucwords($personality); |
| 208: | if (!class_exists($class)) { |
| 209: | return error("unknown personality `%s'", $personality); |
| 210: | } |
| 211: | $this->_instances[$personality] = new $class; |
| 212: | |
| 213: | return $this->_instances[$personality]; |
| 214: | } |
| 215: | |
| 216: | public function insert( |
| 217: | HtaccessContainer $config, |
| 218: | $line, |
| 219: | BaseToken $directive |
| 220: | ) { |
| 221: | if ($line < 0 || !$config->offsetExists($line)) { |
| 222: | return error("invalid offset `%d'", $line); |
| 223: | } |
| 224: | |
| 225: | if (!$this->verify($directive->getName(), $directive->getArguments())) { |
| 226: | return error("unknown directive `%s'", $directive->getName()); |
| 227: | } |
| 228: | $config->insertAt($line, $directive); |
| 229: | |
| 230: | return $config; |
| 231: | } |
| 232: | |
| 233: | public function verify($directive, $val = null, $personality = null) |
| 234: | { |
| 235: | if (!$personality) { |
| 236: | $personality = $this->resolve($directive); |
| 237: | } |
| 238: | if (!$personality) { |
| 239: | return false; |
| 240: | } |
| 241: | if (!$val) { |
| 242: | return true; |
| 243: | } |
| 244: | $instance = $this->_getInstanceFromPersonality($personality); |
| 245: | if (!$instance->resolves($directive)) { |
| 246: | return error("personality `%s' doesn't know how to resolve `%s'", |
| 247: | $personality, |
| 248: | $directive |
| 249: | ); |
| 250: | } |
| 251: | $response = $instance->test($directive, $val); |
| 252: | |
| 253: | if (null === $response) { |
| 254: | return true; |
| 255: | } |
| 256: | |
| 257: | return $response; |
| 258: | } |
| 259: | |
| 260: | |
| 261: | |
| 262: | |
| 263: | |
| 264: | |
| 265: | |
| 266: | public function resolve($token) |
| 267: | { |
| 268: | if (array_key_exists($token, $this->_resolverCache)) { |
| 269: | |
| 270: | return $this->_resolverCache[$token]; |
| 271: | } |
| 272: | $personalities = $this->_tryPersonalities(); |
| 273: | while ($personalities->valid()) { |
| 274: | $name = $personalities->current(); |
| 275: | $p = $this->_getPersonalityFromName($name); |
| 276: | if ($p->resolves($token)) { |
| 277: | $this->_resolverCache[$token] = $name; |
| 278: | |
| 279: | return $name; |
| 280: | } |
| 281: | $personalities->next(); |
| 282: | } |
| 283: | |
| 284: | $this->_resolverCache[$token] = null; |
| 285: | |
| 286: | return false; |
| 287: | } |
| 288: | |
| 289: | private function _getPersonalityFromName($name) |
| 290: | { |
| 291: | if (isset($this->_instances[$name])) { |
| 292: | return $this->_instances[$name]; |
| 293: | } |
| 294: | |
| 295: | |
| 296: | } |
| 297: | |
| 298: | public function get_description($personality, $directive) |
| 299: | { |
| 300: | $personality = strtolower($personality); |
| 301: | $instance = $this->_getInstanceFromPersonality($personality); |
| 302: | if (!$instance) { |
| 303: | return error("unknown personality `%s'", $personality); |
| 304: | } |
| 305: | |
| 306: | return $instance->getTokenDescription($directive); |
| 307: | } |
| 308: | |
| 309: | |
| 310: | |
| 311: | |
| 312: | |
| 313: | |
| 314: | public function remove(HtaccessContainer $config, $line): bool|HtaccessContainer |
| 315: | { |
| 316: | if (!$config->offsetExists($line)) { |
| 317: | return error("invalid offset `%d'", $line); |
| 318: | } |
| 319: | |
| 320: | $config->offsetUnset($line); |
| 321: | |
| 322: | return $config; |
| 323: | } |
| 324: | |
| 325: | |
| 326: | |
| 327: | |
| 328: | |
| 329: | |
| 330: | |
| 331: | |
| 332: | |
| 333: | |
| 334: | |
| 335: | public function replace(HtaccessContainer $config, $directive, $val = '') |
| 336: | { |
| 337: | |
| 338: | $directive = $this->_token2Object($directive); |
| 339: | if (!$this->resolve($directive)) { |
| 340: | return error("unknown directive `%s' specified", $directive); |
| 341: | } |
| 342: | |
| 343: | if ($val && !$this->verify($directive, $val)) { |
| 344: | return error("unknown directive value `%s'", $val); |
| 345: | } |
| 346: | |
| 347: | $token = $config->search($directive); |
| 348: | if ($token) { |
| 349: | dd($token); |
| 350: | if ($val) { |
| 351: | $config[$line] = $directive . ' ' . $val; |
| 352: | } else { |
| 353: | |
| 354: | } |
| 355: | } else { |
| 356: | $config->append("{$directive} {$val}"); |
| 357: | } |
| 358: | |
| 359: | return $config; |
| 360: | } |
| 361: | |
| 362: | |
| 363: | |
| 364: | |
| 365: | |
| 366: | |
| 367: | |
| 368: | |
| 369: | private function _token2Object($token) |
| 370: | { |
| 371: | if ($token instanceof BaseToken) { |
| 372: | return $token; |
| 373: | } |
| 374: | $token = trim($token); |
| 375: | if (!isset($token[0])) { |
| 376: | return new \Tivie\HtaccessParser\Token\WhiteLine($token); |
| 377: | } |
| 378: | switch ($token[0]) { |
| 379: | case '#': |
| 380: | return new \Tivie\HtaccessParser\Token\Comment($token); |
| 381: | case '<': |
| 382: | |
| 383: | break; |
| 384: | default: |
| 385: | |
| 386: | $tokens = explode(' ', $token); |
| 387: | |
| 388: | return new \Tivie\HtaccessParser\Token\Directive($tokens[0], array_slice($tokens, 1)); |
| 389: | |
| 390: | } |
| 391: | $args = explode(' ', substr($token, 1, strpos($token, '>') - 1)); |
| 392: | |
| 393: | $block = new \Tivie\HtaccessParser\Token\Block($args[0]); |
| 394: | if (isset($args[1])) { |
| 395: | $block->setArguments(array_slice($args, 1)); |
| 396: | } |
| 397: | |
| 398: | |
| 399: | $lines = preg_split('/\R/m', $token); |
| 400: | unset($lines[0]); |
| 401: | |
| 402: | for ($i = 1, $n = sizeof($lines); $i < $n; $i++) { |
| 403: | $block->addChild($this->_token2Object($lines[$i])); |
| 404: | } |
| 405: | |
| 406: | return $block; |
| 407: | |
| 408: | } |
| 409: | |
| 410: | public function get_directives($personality) |
| 411: | { |
| 412: | $instance = $this->_getInstanceFromPersonality($personality); |
| 413: | if (!$instance) { |
| 414: | return error("unknown personality `%s'", $instance); |
| 415: | } |
| 416: | |
| 417: | return $instance->getDirectives(); |
| 418: | } |
| 419: | |
| 420: | |
| 421: | |
| 422: | |
| 423: | |
| 424: | |
| 425: | |
| 426: | |
| 427: | |
| 428: | public function commit($host, $hash, $data) |
| 429: | { |
| 430: | $path = ''; |
| 431: | if (is_array($host)) { |
| 432: | if (count($host) != 2) { |
| 433: | return error('host parameter expects at most 2 elements: host and path'); |
| 434: | } |
| 435: | [$host, $path] = $host; |
| 436: | } |
| 437: | $docroot = rtrim($this->web_get_docroot($host) . '/' . $path, '/'); |
| 438: | if (!$docroot) { |
| 439: | return error("unknown host `%s'", $host); |
| 440: | } |
| 441: | $controlpath = $docroot . DIRECTORY_SEPARATOR . self::CONTROL_FILE; |
| 442: | $olddata = ''; |
| 443: | |
| 444: | if ($this->file_exists($controlpath)) { |
| 445: | $olddata = $this->scan($controlpath); |
| 446: | if ($this->hash($olddata) !== $hash) { |
| 447: | return error("control file `%s' out of sync", $controlpath); |
| 448: | } |
| 449: | } |
| 450: | if ($data instanceof HtaccessContainer) { |
| 451: | $data = $data->txtSerialize(); |
| 452: | } |
| 453: | |
| 454: | $data = trim($data); |
| 455: | $res = $this->file_put_file_contents($controlpath, $data); |
| 456: | if (!$res || $res instanceof Exception) { |
| 457: | $reason = 'unknown'; |
| 458: | if ($res instanceof Exception) { |
| 459: | $reason = $res->getMessage(); |
| 460: | } |
| 461: | |
| 462: | return error("failed to update htaccess contents in `%s', reason: %s", $controlpath, $reason); |
| 463: | } |
| 464: | |
| 465: | if (false === strpos($host, '.')) { |
| 466: | $host = $host . '.' . $this->domain; |
| 467: | info('personality applied to global subdomain, ' . |
| 468: | "converting to fqdn `%s'", $host); |
| 469: | } |
| 470: | $myip = (array)$this->common_get_ip_address(); |
| 471: | try { |
| 472: | $http = new HTTP_Request2('http://' . $myip[0]); |
| 473: | $http->setHeader('Host', $host); |
| 474: | $status = $http->send()->getStatus(); |
| 475: | if ($status < 200 || $status >= 500) { |
| 476: | error("inconsistent status `%d' returned, reverted control file %s", |
| 477: | $status, |
| 478: | $controlpath |
| 479: | ); |
| 480: | |
| 481: | $this->file_put_file_contents($controlpath, (string)$olddata); |
| 482: | |
| 483: | return false; |
| 484: | } |
| 485: | } catch (\Exception $e) { |
| 486: | $this->file_put_file_contents($controlpath, (string)$olddata); |
| 487: | |
| 488: | return error("unable to connect to server to test control file, reverting. Error message: `%s'", |
| 489: | $e->getMessage() |
| 490: | ); |
| 491: | } |
| 492: | |
| 493: | return true; |
| 494: | } |
| 495: | |
| 496: | |
| 497: | |
| 498: | |
| 499: | |
| 500: | |
| 501: | |
| 502: | public function hash($obj) |
| 503: | { |
| 504: | if ($obj instanceof \Tivie\HtaccessParser\Parser) { |
| 505: | $obj = $this->_parse($obj); |
| 506: | } else if (!$obj instanceof HtaccessContainer) { |
| 507: | if ($obj[0] !== '/') { |
| 508: | |
| 509: | |
| 510: | |
| 511: | |
| 512: | |
| 513: | |
| 514: | } else { |
| 515: | if (!$this->file_exists($obj)) { |
| 516: | return error("unknown control file `%s'", $obj); |
| 517: | } |
| 518: | |
| 519: | $spl = $this->_getSPLObjectFromFile($obj); |
| 520: | if (!is_object($spl)) { |
| 521: | return error("unknown control hash object `%s'", $obj); |
| 522: | } |
| 523: | $hash = $this->_getCache($spl, 'hash'); |
| 524: | if ($hash) { |
| 525: | |
| 526: | } |
| 527: | $obj = $this->_parse($this->_getParser($spl)); |
| 528: | } |
| 529: | } |
| 530: | |
| 531: | return md5(is_object($obj) ? $obj->txtSerialize() : (string)$obj); |
| 532: | } |
| 533: | |
| 534: | private function _loadPersonalityFromName($name) |
| 535: | { |
| 536: | |
| 537: | } |
| 538: | |
| 539: | |
| 540: | |
| 541: | |
| 542: | |
| 543: | |
| 544: | private function _parse(\Tivie\HtaccessParser\Parser $parser, $file = null) |
| 545: | { |
| 546: | try { |
| 547: | $parsed = $parser->parse($file); |
| 548: | } catch (\Tivie\HtaccessParser\Exception\SyntaxException $e) { |
| 549: | return error("`%s' file is malformed! Check that all blocks are balanced with proper case: %s", |
| 550: | $this->unwrapFileFromParser($parser), |
| 551: | $e->getMessage() |
| 552: | ); |
| 553: | } catch (Exception $e) { |
| 554: | return error("unable to parse control file `%s': %s", |
| 555: | $this->unwrapFileFromParser($parser), |
| 556: | $e->getMessage() |
| 557: | ); |
| 558: | } |
| 559: | |
| 560: | return $parsed; |
| 561: | } |
| 562: | |
| 563: | private function unwrapFileFromParser(\Tivie\HtaccessParser\Parser $p): string |
| 564: | { |
| 565: | $property = (new ReflectionProperty($p, 'file')); |
| 566: | $property->setAccessible(true); |
| 567: | $file = $property->getValue($p); |
| 568: | $file = $file instanceof SplFileObject ? $file->getPathname() : $file; |
| 569: | return $this->file_unmake_path($file); |
| 570: | } |
| 571: | |
| 572: | } |