1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: /**
16: * htaccess driver
17: *
18: * @package core
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: // under modules/
30: const PERSONALITY_MODULE_LOCATION = 'lib/Module/Support/Personality';
31:
32: public $exportedFunctions = array(
33: '*' => PRIVILEGE_SITE
34: );
35:
36: // loaded personalitiesv
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: * @param $file
52: * @return array|ArrayAccess|false
53: * @throws \Tivie\HtaccessParser\Exception\Exception
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: * Store Htaccess Parser
114: *
115: * @param SplFileObject $file
116: * @param \Tivie\HtaccessParser\Parser $parser
117: * @return mixed
118: * @throws \Tivie\HtaccessParser\Exception\Exception
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: // arg! enumeration consumes the queue
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: // if directive/val isn't tested, null is returned
253: if (null === $response) {
254: return true;
255: }
256:
257: return $response;
258: }
259:
260: /**
261: * Resolve a delegating personality
262: *
263: * @param string $token
264: * @return array
265: */
266: public function resolve($token)
267: {
268: if (array_key_exists($token, $this->_resolverCache)) {
269: // don't use isset; unmatched directives will return null
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: * @param HtaccessContainer $config
311: * @param $line
312: * @return bool|HtaccessContainer
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: * Replace a directive
327: *
328: * @param HtaccessContainer $config
329: * @param $line
330: * @param $directive
331: * @param string $val
332: * @return bool|void
333: * @throws \Tivie\HtaccessParser\Exception\DomainException
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: * Convert a string to a htaccess object
364: *
365: * @param string $token
366: * @return BaseToken
367: * @throws \Tivie\HtaccessParser\Exception\DomainException
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: // do extra formatting here
383: break;
384: default:
385: // normal directive
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: // CRLF LF and CR
399: $lines = preg_split('/\R/m', $token);
400: unset($lines[0]);
401: // skip final closing /IfDefine
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: * Write changes to control file
422: *
423: * @param array|string $host hostname or host + path
424: * @param $data
425: * @param $hash validation hash @see hash()
426: * @return bool
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: // do a check to make sure data is consistent
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: // global subdomain -> plop on active domain to make request
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: * Calculate hash of a control file or htaccess object
498: *
499: * @param mixed $obj
500: * @return bool|null|string hash or error
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: // raw file
509: /**
510: * this should only be the case after undergoing Htaccess
511: * parsing @see scan()
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: //return $hash;
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: //return $personalities->offsetGet($name);
537: }
538:
539: /**
540: * @param \Tivie\HtaccessParser\Parser $parser
541: * @param null $file
542: * @return array|ArrayAccess|bool|HtaccessContainer
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: }