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: | } |