1:    2:    3:    4:    5:    6:    7:    8:    9:   10:   11:   12:   13:   14:   15:   16:   17:   18:   19:   20:   21:   22:   23:   24:   25:   26:   27:   28:   29:   30:   31:   32:   33:   34:   35:   36:   37:   38:   39:   40:   41:   42:   43:   44:   45:   46:   47:   48:   49:   50:   51:   52:   53:   54:   55:   56:   57:   58:   59:   60:   61:   62:   63:   64:   65:   66:   67:   68:   69:   70:   71:   72:   73:   74:   75:   76:   77:   78:   79:   80:   81:   82:   83:   84:   85:   86:   87:   88:   89:   90:   91:   92:   93:   94:   95:   96:   97:   98:   99:  100:  101:  102:  103:  104:  105:  106:  107:  108:  109:  110:  111:  112:  113:  114:  115:  116:  117:  118:  119:  120:  121:  122:  123:  124:  125:  126:  127:  128:  129:  130:  131:  132:  133:  134:  135:  136:  137:  138:  139:  140:  141:  142:  143:  144:  145:  146:  147:  148:  149:  150:  151:  152:  153:  154:  155:  156:  157:  158:  159:  160:  161:  162:  163:  164:  165:  166:  167:  168:  169:  170:  171:  172:  173:  174:  175:  176:  177:  178:  179:  180:  181:  182:  183:  184:  185:  186:  187:  188:  189:  190:  191:  192:  193:  194:  195:  196:  197:  198:  199:  200:  201:  202:  203:  204:  205:  206:  207:  208:  209:  210:  211:  212:  213:  214:  215:  216:  217:  218:  219:  220:  221:  222:  223:  224:  225:  226:  227:  228:  229:  230:  231:  232:  233:  234:  235:  236:  237:  238:  239:  240:  241:  242:  243:  244:  245:  246:  247:  248:  249:  250:  251:  252:  253:  254:  255:  256:  257:  258:  259:  260:  261:  262:  263:  264:  265:  266:  267:  268:  269:  270:  271:  272:  273:  274:  275:  276:  277:  278:  279:  280:  281:  282:  283:  284:  285:  286:  287:  288:  289:  290:  291:  292:  293:  294:  295:  296:  297:  298:  299:  300:  301:  302:  303:  304:  305:  306:  307:  308:  309:  310:  311:  312:  313:  314:  315:  316:  317:  318:  319:  320:  321:  322:  323:  324:  325:  326:  327:  328:  329:  330:  331:  332:  333:  334:  335:  336:  337:  338:  339:  340:  341:  342:  343:  344:  345:  346:  347:  348:  349:  350:  351:  352:  353:  354:  355:  356:  357:  358:  359:  360:  361:  362:  363:  364:  365:  366:  367:  368:  369:  370:  371:  372:  373:  374:  375:  376:  377:  378:  379:  380:  381:  382:  383:  384:  385:  386:  387:  388:  389:  390:  391:  392:  393:  394:  395:  396:  397:  398:  399:  400:  401:  402:  403:  404:  405:  406:  407:  408:  409:  410:  411:  412:  413:  414:  415:  416:  417:  418:  419:  420:  421:  422:  423:  424:  425:  426:  427:  428:  429:  430:  431:  432:  433:  434:  435:  436:  437:  438:  439:  440:  441:  442:  443:  444:  445:  446:  447:  448:  449:  450:  451:  452:  453:  454:  455:  456:  457:  458:  459:  460:  461:  462:  463:  464:  465:  466:  467:  468:  469:  470:  471:  472:  473:  474:  475:  476:  477:  478:  479:  480:  481:  482:  483:  484:  485:  486:  487:  488:  489:  490:  491:  492:  493:  494:  495:  496:  497:  498:  499:  500:  501:  502:  503:  504:  505:  506:  507:  508:  509:  510:  511:  512:  513:  514:  515:  516:  517:  518:  519:  520:  521:  522:  523:  524:  525:  526:  527:  528:  529:  530:  531:  532:  533:  534:  535:  536:  537:  538:  539:  540:  541:  542:  543:  544:  545:  546:  547:  548:  549:  550:  551:  552:  553:  554:  555:  556:  557:  558:  559:  560:  561:  562:  563:  564:  565:  566:  567:  568:  569:  570:  571:  572:  573:  574:  575:  576:  577:  578:  579:  580:  581:  582:  583:  584:  585:  586:  587:  588:  589:  590:  591:  592:  593:  594:  595:  596:  597:  598:  599:  600:  601:  602:  603:  604:  605:  606:  607:  608:  609:  610:  611:  612:  613:  614:  615:  616:  617:  618:  619:  620:  621:  622:  623:  624:  625:  626:  627:  628:  629:  630:  631:  632:  633:  634:  635:  636:  637:  638:  639:  640:  641:  642:  643:  644:  645:  646:  647:  648:  649:  650:  651:  652:  653:  654:  655:  656:  657:  658:  659:  660:  661:  662:  663:  664:  665:  666:  667:  668:  669:  670:  671:  672:  673:  674:  675:  676:  677:  678:  679:  680:  681:  682:  683:  684:  685:  686:  687:  688:  689:  690:  691:  692:  693:  694:  695:  696:  697:  698:  699:  700:  701:  702:  703:  704:  705:  706:  707:  708:  709:  710:  711:  712:  713:  714:  715:  716:  717:  718:  719:  720:  721:  722:  723:  724:  725:  726:  727:  728:  729:  730:  731:  732:  733:  734:  735:  736:  737:  738:  739:  740:  741:  742:  743:  744:  745:  746:  747:  748:  749:  750:  751:  752:  753:  754:  755:  756:  757:  758:  759:  760:  761:  762:  763:  764:  765:  766:  767:  768:  769:  770:  771:  772:  773:  774:  775:  776:  777:  778:  779:  780:  781:  782:  783:  784:  785:  786:  787:  788:  789:  790:  791:  792:  793:  794:  795:  796:  797:  798:  799:  800:  801:  802:  803:  804:  805:  806:  807:  808:  809:  810:  811:  812:  813:  814:  815:  816:  817:  818:  819:  820:  821:  822:  823:  824:  825:  826:  827:  828:  829:  830:  831:  832:  833:  834:  835:  836:  837:  838:  839:  840:  841:  842:  843:  844:  845:  846:  847:  848:  849:  850:  851:  852:  853:  854:  855:  856:  857:  858:  859:  860:  861:  862:  863:  864:  865:  866:  867:  868:  869:  870:  871:  872:  873:  874:  875:  876:  877:  878:  879:  880:  881:  882:  883:  884:  885:  886:  887:  888:  889:  890:  891:  892:  893:  894:  895:  896:  897:  898:  899:  900:  901:  902:  903:  904:  905:  906:  907:  908:  909:  910:  911:  912:  913:  914:  915:  916:  917:  918:  919:  920:  921:  922:  923:  924:  925:  926:  927:  928:  929:  930:  931:  932:  933:  934:  935:  936:  937:  938:  939:  940:  941:  942:  943:  944:  945:  946:  947:  948:  949:  950:  951:  952:  953:  954:  955:  956:  957:  958:  959:  960:  961:  962:  963:  964:  965:  966:  967:  968:  969:  970:  971:  972:  973:  974:  975:  976:  977:  978:  979:  980:  981:  982:  983:  984:  985:  986:  987:  988:  989:  990:  991:  992:  993:  994:  995:  996:  997:  998:  999: 1000: 1001: 1002: 1003: 1004: 1005: 1006: 1007: 1008: 1009: 1010: 1011: 1012: 1013: 1014: 1015: 1016: 1017: 1018: 1019: 1020: 1021: 1022: 1023: 1024: 1025: 1026: 1027: 1028: 1029: 1030: 1031: 1032: 1033: 1034: 1035: 1036: 1037: 1038: 1039: 1040: 1041: 1042: 1043: 1044: 1045: 1046: 1047: 1048: 1049: 1050: 1051: 1052: 1053: 1054: 1055: 1056: 1057: 1058: 1059: 1060: 1061: 1062: 1063: 1064: 1065: 1066: 1067: 1068: 1069: 1070: 1071: 1072: 1073: 1074: 1075: 1076: 1077: 1078: 1079: 1080: 1081: 1082: 1083: 1084: 1085: 1086: 1087: 1088: 1089: 1090: 1091: 1092: 1093: 1094: 1095: 1096: 1097: 1098: 1099: 1100: 1101: 1102: 1103: 1104: 1105: 1106: 1107: 1108: 1109: 1110: 1111: 1112: 1113: 1114: 1115: 1116: 1117: 1118: 1119: 1120: 1121: 1122: 1123: 1124: 1125: 1126: 1127: 1128: 1129: 1130: 1131: 
<?php

    /**
     * Copyright (C) Apis Networks, Inc - All Rights Reserved.
     *
     * Unauthorized copying of this file, via any medium, is
     * strictly prohibited without consent. Any dissemination of
     * material herein is prohibited.
     *
     * For licensing inquiries email <licensing@apisnetworks.com>
     *
     * Written by Matt Saladna <matt@apisnetworks.com>, May 2017
     */
    namespace Module\Support;

    use File_Module, Web_Module, User_Module;
    use Module\Support\Webapps\MetaManager;
    use Opcenter\Versioning;

    abstract class Webapps extends \Module_Skeleton implements Webapps\Contracts\Webapp
    {
        use \NamespaceUtilitiesTrait;

        const APPLICATION_PREF_KEY = 'webapps.paths';
        const APP_NAME = 'undefined';
        const DEFAULT_VERSION_LOCK = 'none';

        public $exportedFunctions = array(
            '*'            => PRIVILEGE_SITE | PRIVILEGE_USER,
            'get_versions' => PRIVILEGE_ALL,
            'is_current'   => PRIVILEGE_ALL,
            'next_version' => PRIVILEGE_ALL
        );


        /**
         * Restrict write-access by the app
         *
         * @param string $hostname
         * @param string $path
         * @param string $mode
         * @return bool
         */
        public function fortify(string $hostname, string $path = '', string $mode = 'max'): bool
        {
            $approot = $this->getAppRoot($hostname, $path);

            if (!$approot || !$this->valid($hostname, $path)) {
                return error("path `%s' is not a %s install", $approot, $this->getInternalName());
            }
            if (!isset($this->_aclList[$mode])) {
                return error("unknown mode `%s'", $mode);
            }
            $username = $this->getDocrootUser($approot);
            // clear everything out
            $this->file_set_acls($approot, null, array(File_Module::ACL_MODE_RECURSIVE));
            $files = $this->_mapFiles($this->getACLFiles($mode, $approot), $approot);

            $users = array(
                array(Web_Module::WEB_USERNAME => 'drwx'),
                array(Web_Module::WEB_USERNAME => 'rwx'),
                array($username => 'rwx'),
                array($username => 'drwx'),
            );
            $flags = array(
                File_Module::ACL_MODE_RECURSIVE => true,
            );

            // @TODO clobber ownership?
            if (!$this->file_set_acls($files, $users, $flags)) {
                return warn("fortification failed on `%s/%s'", $hostname, $path);
            }
            $this->setOptions($approot, ['fortify' => $mode]);

            return true;
        }

        /**
         * Get ACL files
         *
         * @param string $mode
         * @param string app root
         * @return array
         */
        protected function getACLFiles(string $mode, string $approot): array {
            return $this->_aclList[$mode];
        }

        public function failed($docroot, $failedFlag = null) {
            $prefs = $this->getMap($docroot);
            if ($failedFlag === null) {
                return array_get($prefs, 'failed', false);
            }
            $prefs['failed'] = (bool)$failedFlag;

            return $this->setMap($docroot, $prefs);
        }

        /**
         * Convert hostname/path into application root following symlinks
         *
         * @param string $hostname
         * @param string $path
         * @return bool|string
         */
        protected function getAppRoot(string $hostname, string $path = ''): ?string
        {
            if (!($approot = $this->web_normalize_path($hostname, $path))) {
                return null;
            }
            if (!$stat = $this->file_stat($approot)) {
                // directory doesn't exist yet
                return $approot;
            }

            if ($stat['link']) {
                $approot = $stat['referent'];
            }

            return $approot ?: null;
        }

        /**
         * Convert hostname/path into account path
         *
         * @param string $hostname subdomain or domain
         * @param string $path     optional path under hostname
         * @return bool|string
         */
        protected function getDocumentRoot(string $hostname, string $path = ''): ?string {
            return $this->web_get_docroot($hostname, $path);
        }

        abstract public function valid(string $hostname, string $path = ''): bool;

        protected function getInternalName()
        {
            return strtolower(static::APP_NAME);
        }

        public function getVersionLock($docroot) {
            $options = $this->getOptions($docroot);
            $lock = array_get($options, 'verlock', null);
            if ($lock === 'none') {
                return false;
            }

            return $lock ?? static::DEFAULT_VERSION_LOCK;
        }


        /**
         * Get non-system user docroot ownership
         *
         * @param string $docroot
         * @return string user or active username if system user
         */
        protected function getDocrootUser(string $docroot): string
        {
            if (!($this->permission_level & PRIVILEGE_SITE)) {
                return $this->username;
            }
            $stat = $this->file_stat($docroot);
            if (!$stat) {
                return $this->username;
            }
            // don't change if system user
            if ($stat['uid'] < \apnscpFunctionInterceptor::get_autoload_class_from_module('user')::MIN_UID) {
                warn("docroot `%s' is owned by system user `%d'", $docroot, $stat['uid']);

                return $this->username;
            }
            if (!($username = $this->user_get_username_from_uid($stat['uid']))) {
                warn("failed to translate uid `%d' to user, defaulting ownership of `%s' to `%s'",
                    $stat['uid'], $docroot, $this->username);

                return $this->username;
            }

            return $username;
        }

        /**
         * Create an afi instance based on directory ownership
         *
         * @param string $docroot
         * @param \Auth_Info_User|null   $context context reference
         * @return \apnscpFunctionInterceptor
         */
        protected function getApnscpFunctionInterceptorFromDocroot(string $docroot, &$context = null): \apnscpFunctionInterceptor
        {
            $user = $this->getDocrootUser($docroot);
            if ($user === $this->username) {
                $context = $this->getAuthContext();
                return $this->getApnscpFunctionInterceptor();
            }
            $context = Auth::context($user, $this->site);
            return \apnscpFunctionInterceptor::factory($context);
        }

        /**
         * Create docroot map of files
         *
         * @param array  $files
         * @param string $docroot
         * @return array
         */
        protected function _mapFiles(array $files, string $docroot): array
        {
            $prefix = $this->domain_fs_path();

            return array_filter(array_map(function ($f) use ($docroot, $prefix) {
                return $this->buildFileMapList($f, $docroot, $prefix);
            }, $files));
        }

        /**
         * Build map of physical files from list
         *
         * @param string $f
         * @param string $docroot
         * @param string $prefix
         * @return null|string
         */
        protected function buildFileMapList(string $f, string $docroot, string $prefix): ?string
        {
            if ($f[0] !== '/') {
                $f = $docroot . '/' . $f;
            }
            $path = $prefix . $f;
            /**
             * allow specifying additional files that may not
             * exist in the install
             */
            if (false === strpbrk($f, '[]*') && !file_exists($path)) {
                return null;
            }

            return $f;
        }

        public function setOptions(string $docroot, ?array $options)
        {
            return $this->setMap($docroot, 'options', array_replace($this->getOptions($docroot), $options));
        }

        /**
         * Set or replace single element of map
         * @param string $docroot
         * @param string|array $param
         * @param null $val
         * @return bool
         */
        private function setMap($docroot, $param, $val = null)
        {
            $docroot = rtrim($docroot, '/');
            $map = $this->getMap($docroot);
            if (!\is_array($param)) {
                if (null === $val) {
                    unset($map[$param]);
                } else {
                    $map[$param] = $val;
                }
            }  else {
                $map = $param;
            }
            return $this->saveMap($docroot, $map);
        }

        /**
         * Account has sufficient memory in MB
         *
         * @param int $memory memory in MB
         * @param     $available optional memory available
         * @return bool
         */
        protected function hasMemoryAllowance(int $memory, &$available = null): bool {
            return !$this->cgroup_enabled() || ($available = $this->getConfig('cgroup', 'memory',
                \Opcenter\System\Memory::stats()['memtotal'] / 1024)) >= $memory;
        }

        /**
         * Account has sufficient storage in MB
         *
         * @param int  $storage storage in MB
         * @param null $available
         * @return bool
         */
        protected function hasStorageAllowance(int $storage, &$available = null): bool {
            $quota = $this->site_get_account_quota();
            if (!$this->getConfig('diskquota', 'enabled')) {
                return true;
            }

            $available = (int)(($quota['qhard'] - $quota['qused']) / 1024);
            return $available >= $storage;
        }

        /**
         * Get webapp metadata
         *
         * @param string $docroot
         * @return array
         */
        private function getMap(string $docroot): array
        {
            $docroot = rtrim($docroot, '/');
            $map = MetaManager::instantiateContexted($this->getAuthContext());
            return $map->get($docroot);
        }

        /**
         * Migrate document root elsewhere
         *
         * @param string $docroot
         */
        protected function movePrimaryDocumentRoot(string $docroot)
        {
            if ($docroot !== \Web_Module::MAIN_DOC_ROOT) {
                return $docroot;
            }
            $app = $this->getInternalName();
            // @todo objects make more sense now...
            $suffix = '';
            $i = 0;
            do {
                $newroot = \dirname($docroot) . DIRECTORY_SEPARATOR . 'html-' . $app . $suffix;
                if (!$this->file_exists($newroot)) {
                    break;
                }
                $newroot = null;
                $i++;
                $suffix = '-' . date('Ymd-' . $i);
            } while ($i < 100);

            if (null === $newroot) {
                return error("failed to rename docroot `%s' - cannot find a suitable location", $docroot);
            }

            if ($this->file_exists($newroot)) {
                return error("attempted to install %s in `%s', but directory exists - remove first before proceeding",
                    ucwords($app), $newroot);
            }
            if (!$this->file_rename($docroot, $newroot)) {
                return error("failed to rename docroot from `%s' to `%s'", $docroot, $newroot);
            }
            info("Relocated primary document root from `%s' to `%s'", $docroot, $newroot);
            $this->web_purge();
            return $newroot;
        }

        /**
         * Migrate a document root to public/
         *
         * @param string $hostname hostname
         * @param string $path subdomain path
         * @param string $public optional public folder
         * @return null|string
         */
        protected function remapPublic(string $hostname, string $path = '', string $public = 'public'): ?string
        {
            $rootsave = $this->getDocumentRoot($hostname, $path);
            if (false === ($docroot = $this->movePrimaryDocumentRoot($rootsave))) {
                warn("failed to relocate docroot for %s - installation incomplete", ucwords($this->getInternalName()));
                return null;
            }
            if ($rootsave !== $docroot) {
                $stat = $this->file_stat($rootsave);
                // remove dangling symlink
                if ($stat && $stat['link']) {
                    $this->file_delete($rootsave, false);
                }
            }

            if (!$this->file_exists("${docroot}/${public}")) {
                if (!$stat = $this->file_stat($docroot)) {
                    error("document root `%s' missing - unable to stat", $docroot);
                    return null;
                }
                $this->file_create_directory("${docroot}/${public}");
                $this->file_chown("${docroot}/${public}", $stat['owner']);
            }
            if ($rootsave !== $docroot) {
                // docroot was moved, bad fringe case
                $stat = $this->file_stat($docroot);
                if (!$stat) {
                    error("emerg! failed to stat path `%s'", $docroot);
                    return null;
                }

                $this->file_symlink($docroot . '/' . $public, $rootsave);
                $this->file_chown_symlink($rootsave, $stat['owner'], true);
                $this->web_purge();
                return $rootsave;
            }

            $normalized = $this->web_normalize_hostname($hostname);
            [$subdomain, $domain] = array_values($this->web_split_host($normalized));

            $ret = true;
            if ($docroot !== rtrim("${docroot}/${public}",'/')) {
                // in case of /var/www/html no modification is performed
                if ($subdomain) {
                    $ret = $this->web_rename_subdomain($hostname, null, "${docroot}/${public}")
                        && info("Moved document root for `%s' from `%s' to `%s'", $normalized, $docroot, "${docroot}/${public}");
                } else {
                    $ret = $this->aliases_modify_domain($domain, ['path' => "${docroot}/${public}"])
                        && info("Moved document root for `%s' from `%s' to `%s'", $normalized, $docroot, "${docroot}/${public}");
                }
            }
            $this->web_purge();
            return !$ret ? null : "${docroot}/${public}";
        }

        private function saveMap($docroot, $map)
        {
            $docroot = rtrim($docroot, '/');
            // can't use \Preferences::get('webapps.paths.foo.com') because foo.com -> foo['com']
            $prefs = MetaManager::instantiateContexted($this->getAuthContext());
            if (null === $map) {
                $prefs->forget($docroot);
            } else {
                $prefs->set($docroot, $map);
            }
            return true;
        }

        /**
         * Web application supports fortification
         *
         * @param string|null $mode optional mode (min, max)
         * @return bool
         */
        public function has_fortification(string $mode = null): bool
        {
            if ($mode) {
                return isset($this->_aclList[$mode]);
            }

            return !empty($this->_aclList);
        }

        /**
         * Relax permissions to allow write-access
         *
         * @param string $hostname
         * @param string $path
         * @return bool
         */
        public function unfortify(string $hostname, string $path = ''): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot || !$this->valid($hostname, $path)) {
                return error("path `%s' is not a " . static::APP_NAME . '  install', $docroot);
            }
            $prefs = $this->getOptions($docroot);
            $users = array(
                array(Web_Module::WEB_USERNAME => 'rxw'),
                array(Web_Module::WEB_USERNAME => 'drwx'),
                $this->username => 'drwx'
            );
            if (!$this->file_set_acls($docroot, $users, array(File_Module::ACL_MODE_RECURSIVE => true))) {
                return warn("unfortification failed on `%s/%s'", $hostname, $path);
            }
            $prefs['fortify'] = false;
            $this->setOptions($docroot, $prefs);

            return true;
        }

        /**
         * Get webapp options
         *
         * @param $docroot
         * @return mixed
         */
        public function getOptions($docroot)
        {
            return array_get($this->getMap($docroot), 'options', []);
        }

        /**
         * Remove an installed web application
         *
         * @param string $hostname
         * @param string $path
         * @param string $delete "all", "db", or "files"
         * @return bool
         */
        public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool
        {
            $docroot = $this->getDocumentRoot($hostname, $path);
            if (!$docroot) {
                return error('failed to determine path');
            }

            if (!$this->valid($hostname, $path)) {
                return error("`%s' does not contain a valid " . static::APP_NAME . " install", $docroot);
            }

            $delete = strtolower($delete);
            if ($delete && !\in_array($delete, array('all', 'db', 'files'), true)) {
                return error("unknown delete option `%s'", $delete);
            }

            $config = $this->db_config($hostname, $path);
            $dbtype = $config['type'] ?? 'mysql';
            if ($delete === 'all' || $delete === 'db') {
                if (!$config) {
                    warn('cannot remove database, config missing?');
                } else if ($this->{$dbtype . '_database_exists'}($config['db']) && !$this->{$dbtype . '_delete_database'}($config['db'])) {
                    warn("failed to delete mysql database `%s'", $config['db']);
                } else if ($config['user'] !== $this->getServiceValue($dbtype, 'dbaseadmin')) {
                    if ($this->{$dbtype . '_user_exists'}($config['user'], $config['host']) && !$this->{$dbtype . '_delete_user'}($config['user'], $config['host'])
                    ) {
                        warn("failed to delete mysql user `%s' on localhost", $config['user']);
                    }
                }
            } else if ($config) {
                warn("database kept, delete user: `%s'@`%s', db: `%s' manually",
                    $config['user'],
                    $config['host'],
                    $config['db']
                );
            }
            $options = $this->getOptions($docroot);
            $this->map('delete', $docroot);
            if (!$delete || $delete === 'db') {
                return info("removed configuration, manually delete files under `%s'", $docroot);
            }
            $approot = $this->getAppRoot($hostname, $path);
            $this->file_delete($approot, true);
            if ($approot !== $docroot && 0 !== strpos("${docroot}/", "${approot}/")) {
                // remapping /var/www/html to /var/www/ghost/public
                $this->file_delete($docroot);
            }
            $url = rtrim(join('/', array($hostname, $path)), '/');
            $this->file_purge();
            $this->file_create_directory($docroot, 0755, true);
            if (!array_get($options, 'squash', true)) {
                $this->unsquash($docroot);
            }

            return info("deleted %s `%s' located under `%s'", static::APP_NAME, $url, $docroot);
        }

        protected function map($mode, $docroot, array $params = null)
        {
            if ($mode != 'add' && $mode != 'delete') {
                return error("failed to set map for `%s', unknown mode `%s'",
                    $docroot, $mode);
            }

            if ($mode == 'delete') {
                return $this->saveMap($docroot, null);
            }

            $prefs = array_merge($this->getMap($docroot), $params);
            $prefs['type'] = $this->getModule();

            return $this->saveMap($docroot, $prefs);
        }

        protected function getModule()
        {
            $class = \get_class($this);

            return strtolower(substr($class, 0, strpos($class, '_'))) ?? 'skeleton';
        }

        protected function checkEmail(array &$options): bool
        {
            if (isset($options['email']) && !preg_match(Regex::EMAIL, $options['email'])) {
                return error("invalid email address `%s' specified", $options['email']);
            }
            $options['email'] = $this->common_get_email();
            return true;
        }
        /**
         * Installation requested squash
         *
         * @param array $options
         * @return bool
         */
        protected function prepareSquash(array &$options): bool
        {
            $squash = array_get($options, 'squash', false);
            if ($squash && $this->permission_level & PRIVILEGE_USER) {
                warn('must squash privileges as secondary user');
                $squash = true;
            }
            $options['squash'] = $squash;
            return (bool)$squash;
        }

        /**
         * Check and prepare version information
         *
         * @param array $options
         * @return bool
         */
        protected function checkVersion(array &$options): bool
        {
            $version = array_get($options, 'version');
            if (null === $version) {
                $version =  $this->get_versions();
                $version = array_pop($version);
            } else if (null !== $version && strcspn($version, ".0123456789")) {
                return error("invalid version number, %s", $version);
            }
            $options['version'] = $version;
            return true;
        }
        /**
         * Change ownership from active uid to parent directory uid
         *
         * @param string $docroot document root
         * @return bool
         */
        protected function unsquash(string $docroot): bool
        {
            $stat = $this->file_stat(\dirname($docroot));
            $newuid = array_first([$stat['uid'], $this->user_id], function ($uid) {
                return $uid >= User_Module::MIN_UID;
            });
            if ($newuid === $this->user_id) {
                return true;
            }
            $ret = $this->file_chown($docroot, $newuid, true);
            if (!$ret) {
                warn("failed to unsquash `%s', ownership will remain as `%s'", $docroot, $this->username);
            }
            // @todo this is a kludge to allow updates by site admin without sudo to user
            // adjust update routines to sudo?
            $this->file_set_acls(
                $docroot,
                $this->username,
                'rwx',
                [File_Module::ACL_MODE_RECURSIVE, File_Module::ACL_MODE_RECURSIVE]
            );

            return true;
        }

        /**
         * Get next version in hierarchy
         *
         * @param string $version
         * @param string $maximalbranch
         * @return null|string
         */
        public function next_version(string $version, string $maximalbranch = '99999999.99999999.99999999'): ?string
        {
            $versions = $this->get_versions();

            return Versioning::nextVersion($versions, $version, $maximalbranch);
        }

        /**
         * Check if version is latest or get latest version
         *
         * @param null|string $version
         * @param string|null $branchcomp branch to compare against
         * @return int|string
         */
        public function is_current(string $version = null, string $branchcomp = null)
        {
            $versions = $this->get_versions();
            if (!$version) {
                return array_pop($versions);
            }

            return Versioning::current($versions, $version, $branchcomp);
        }

        public function _cron()
        {

        }

        /**
         * Verify docroot is writeable before beginning installation
         *
         * @param      $docroot
         * @param null $user optional docroot owner
         * @return bool|void
         */
        protected function checkDocroot($docroot, $user = null)
        {
            if (!$user) {
                $user = $this->username;
            }
            if (!$this->user_exists($user)) {
                return error("target user for install `%s' does not exist", $user);
            }
            $this->file_purge();

            if (!$this->file_exists($docroot) && !$this->file_create_directory($docroot, 0755, true)) {
                return error("failed to create installation directory `%s'", $docroot);
            }


            $publicpath = $this->domain_fs_path() . DIRECTORY_SEPARATOR . $docroot;
            $files = glob($publicpath . DIRECTORY_SEPARATOR . '*');

            $placeholder = $docroot . '/index.html';
            if (\count($files) === 1 && $this->file_exists($placeholder)) {
                $this->file_delete($placeholder);
                info('removed placeholder file');
            } else if (!empty($files)) {
                return error("target path `%s' must be empty, %d files located in `%s'", $docroot, \count($files), $docroot);
            }
            $this->file_chown($docroot, $user);
            $this->file_purge();

            return true;
        }

        /**
         * Kill processes running under location
         *
         * @param $hostname
         * @param $path
         * @return int
         */
        protected function kill($hostname, $path): int
        {
            $approot = $this->getAppRoot($hostname, $path);
            $procs = $this->pman_get_processes();
            $count = 0;
            foreach ($procs as $pid => $data) {
                if (0 === strpos($data['cwd'], $approot)) {
                    $this->pman_kill($pid) && $count++;
                }
            }

            return $count;
        }

        /**
         * Generate a DB name for installation
         *
         * @todo extract to Module_Support_XXX
         *
         * @param        $domain
         * @param string $type
         * @return bool|string
         */
        protected function _suggestDB($domain, $type = 'mysql')
        {
            $suffix = '';
            $dbprefix = $this->sql_get_prefix();
            $maxlen = $this->sql_mysql_schema_column_maxlen('db');
            $normalizedname = substr(
                str_replace(array('.', '-'), '', $domain), 0, $maxlen);
            // room for -##
            if ((\strlen($normalizedname) + 3) >= $maxlen) {
                $pos = null;
                if (false === ($pos = strpos($domain, '.'))) {
                    $pos = $maxlen - 5;
                }
                $normalizedname = substr($normalizedname, 0, $pos);
            }
            for ($i = 1; $i < 100; $i++) {
                $name = $dbprefix . $normalizedname . $suffix;
                if (\strlen($name) > $maxlen) {
                    warn('db name generation exceeds maximum allowable bounds, generating a random db name');
                    $name = $dbprefix . crc32(time());
                }
                if (!$this->{$type . '_database_exists'}($name)) {
                    return $name;
                }
                $suffix = '-' . $i;
            }

            return error('cannot generate a suitable database, pool exhausted');
        }

        /**
         * Suggest a user given a database
         *
         * @todo extract to Module_Support_XXX
         *
         * @param        $dbname suggested db name
         * @param string $host   optional hostname
         * @param string $type
         * @return bool|int|string|void
         */
        protected function _suggestUser($dbname, $host = 'localhost', $type = 'mysql')
        {
            $suffix = '';
            $dbprefix = $this->sql_get_prefix();
            if (!strncmp($dbname, $dbprefix, \strlen($dbprefix))) {
                $dbname = substr($dbname, \strlen($dbprefix));
            }
            $maxlen = $this->sql_mysql_schema_column_maxlen('user');
            $normalizedname = substr(
                str_replace(array('.', '-'), '', $dbname), 0, $maxlen);
            if ((\strlen($normalizedname) + 3) > $maxlen) {
                $pos = null;
                if (false === ($pos = strpos($dbname, '.'))) {
                    $pos = $maxlen - 5;
                }
                $normalizedname = substr($normalizedname, 0, $pos);
            }
            for ($i = 1; $i < 20; $i++) {
                $name = $dbprefix . $normalizedname . $suffix;
                if (\strlen($name) > $maxlen) {
                    warn('db username generation exceeds maximum allowable bounds, generating a random username');
                    $name = crc32(time());
                    $dblen = \strlen($dbprefix);
                    $name = $dbprefix . substr($name, 0, $maxlen - $dblen);
                }
                if (!$this->{$type . '_user_exists'}($name, $host)) {
                    return $name;
                }
                $suffix = '-' . $i;
            }

            return error('cannot generate a suitable database, pool exhausted');
        }

        /**
         * Suggest a password
         *
         * @todo extract to Module_Support_XXX
         *
         * @return string
         */
        protected function suggestPassword(int $maxlen = 32): string
        {
            return \Opcenter\Auth\Password::generate($maxlen);
        }

        /**
         * Populate database
         *
         * @param array  $credentials
         * @param string $type
         * @return bool
         */
        protected function setupDatabase(array $credentials, $type = 'mysql'): bool
        {
            $db = $credentials['db'];
            $dbuser = $credentials['user'];
            $dbpass = $credentials['password'];
            $dbhost = $credentials['host'] ?? 'localhost';
            $maxconn = $credentials['max_connections'] ?? \Mysql_Module::DEFAULT_CONCURRENCY_LIMIT;
            if (!\apnscpFunctionInterceptor::module_exists($type)) {
                return error("Unknown database type `%s'", $type);
            }
            if (!$this->{$type . '_create_database'}($db)) {
                return error("failed to create suggested db `%s'", $db);
            }
            $userargs = [];
            if ($type === 'pgsql') {
                $userargs = [
                    $dbuser, $dbpass, $maxconn
                ];
            } else {
                $userargs = [
                    $dbuser, $dbhost, $dbpass, $maxconn
                ];
            }
            if (!$this->{$type . '_add_user'}(...$userargs)) {
                $this->{$type . '_delete_database'}($db);

                return error("failed to create suggested user `%s'", $dbuser);
            }
            $oldex = \Error_Reporter::exception_upgrade();
            try {
                if ($type === 'pgsql') {
                    $this->pgsql_set_owner($db, $dbuser);
                } else {
                    $this->{$type . '_set_privileges'}($dbuser, 'localhost', $db, array('read' => true, 'write' => true));
                }
            } catch (\apnscpException $e) {
                $this->{$type . '_delete_user'}($dbuser, 'localhost');
                $this->{$type . '_delete_database'}($db);
                return error("failed to set privileges on db `%s' for user `%s'", $db, $dbuser);
            } finally {
                \Error_Reporter::exception_upgrade($oldex);
            }

            if ($this->{$type . '_add_backup'}($db, 'zip', 5, 2)) {
                info("added database backup task for `%s'", $db);
            }

            return true;
        }

        /**
         * Get latest release
         *
         * @param string $branch branch to discover latest version
         * @return null|string
         */
        protected function getLatestVersion(string $branch = null): ?string
        {
            $versions = $this->get_versions();
            if (!$versions) {
                return null;
            }
            if (!$branch) {
                return array_pop($versions);
            }

            $latest = null;
            $search = $branch . '.0';

            foreach ($versions as $version) {
                if (0 === strpos($version, $branch) && version_compare($version, $search, '>=')) {
                    $latest = $version;
                }
            }

            return $latest;
        }

        protected function fixRewriteBase($docroot, $path = '')
        {
            $file = $docroot . '/.htaccess';
            $htaccess = $this->file_get_file_contents($file);
            $replacement = '$1RewriteEngine On' . "\n" .
                '$1RewriteBase ' . '/' . ltrim($path, '/');

            $newhtaccess = preg_replace('/^(\s*)RewriteEngine On(?!\s+RewriteBase)/mi', $replacement, $htaccess);
            $this->file_put_file_contents($file, $newhtaccess);
        }

        /**
         * @param string $url
         * @param string $dest
         * @param bool   $extract
         * @throws HTTP_Request2_Exception
         * @return string|bool
         */
        protected function download(string $url, string $dest, bool $extract = true)
        {
            $prefix = $this->domain_fs_path();
            // necessary because sometimes file_extract can be dumb
            $basename = basename($url);
            if (false !== ($pos = strpos($basename, '?'))) {
                $basename = substr($basename, 0, $pos);
            }
            if (false !== ($pos = strpos($basename, '.tar.'))) {
                $suffix = substr($basename, $pos);
            } else {
                $suffix = substr($basename, strrpos($basename, '.'));
            }

            $tmp = tempnam($prefix . '/' . TEMP_DIR, 'wget') . $suffix;
            if (file_exists($tmp)) {
                return error("cannot download, dest file/suffix `%s' exists", $tmp);
            }
            if (\extension_loaded('curl')) {
                $adapter = new \HTTP_Request2_Adapter_Curl();
            } else {
                $adapter = new \HTTP_Request2_Adapter_Socket();
            }

            $http = new \HTTP_Request2(
                $url,
                \HTTP_Request2::METHOD_GET,
                array(
                    'adapter'    => $adapter,
                    'store_body' => false
                )
            );

            $observer = new \HTTP_Request2_Observer_SaveDisk($tmp);
            $http->attach($observer);
            try {
                $response = $http->send();
                $code = $response->getStatus();
                switch ($code) {
                    case 200:
                        break;
                    case 403:
                        return error("URL `%s' request forbidden by server", $url);
                    case 404:
                        return error("URL `%s' not found on server", $url);
                    case 301:
                    case 302:
                        $newLocation = $response->getHeader('location');

                        return self::download($newLocation, $dest, $extract);
                    default:
                        return error("URL request failed, code `%d': %s",
                            $code, $response->getReasonPhrase());
                }
                $content = $response->getHeader('content-type');
                $okcontent = array('application/octet-stream', 'application/zip');
                if (!\in_array($content, $okcontent, true)) {
                    \Error_Reporter::report($content);
                }
                // this returns nothing as xfer is saved directly to disk
                $http->getBody();
            } catch (\HTTP_Request2_Exception $e) {
                return error("fatal error retrieving URL: `%s'", $e->getMessage());
            }
            if (!posix_getuid()) {
                chown($tmp, WS_USER);
            }
            // write file
            $jailtmp = $this->file_unmake_path($tmp);
            $this->file_endow_upload(basename($jailtmp));
            if (version_compare(platform_version(), '6.5', '>=')) {
                // Luna+ platforms use OverlayFS
                $this->file_purge();
            }
            if ($extract && $this->file_is_compressed($jailtmp)) {
                if (!$this->file_extract($jailtmp, $dest)) {
                    error("failed to extract downloaded file to `%s'", $dest);
                    $dest = null;
                }
            }
            $this->file_delete($jailtmp);

            return $dest;
        }

        /**
         * Set webapp meta
         *
         * @param string     $docroot document root
         * @param array|null $info
         * @return bool
         */
        protected function setInfo(string $docroot, ?array $info)
        {
            $old = $this->getMap($docroot);
            return $this->setMap($docroot, array_replace($old, $info));
        }

        protected function _getWebappExtraStorageDirectory()
        {
            return resource_path('storehouse');
        }

        public static function knownApps(): array {
            /**
             * Localize apps per account per featureset in the future?
             */
            $key = 'webapps.applist';
            $cache = \Cache_Global::spawn();
            $apps = $cache->get($key);
            if (false !== $apps) {
                return $apps;
            }

            $apps = array_filter(array_map(function ($app) {
                if (substr($app, -4) !== '.php') {
                    return false;
                }
                $name = substr($app, 0, -4);
                $appclass = 'AppType_' . ucwords($name);
                $class = (new \ReflectionClass(self::appendNamespace($appclass)))->newInstanceWithoutConstructor();
                if ($class->display()) {
                    return strtolower($name);
                }

                return false;
            }, Filesystem::readdir(__DIR__ . DIRECTORY_SEPARATOR . 'AppType')));
            asort($apps);
            $cache->set($key, $apps, 300);

            return $apps;
        }

        public function configureSsl(string $hostname) {
            if (false === strpos($hostname, '.')) {
                $tmp = $this->web_normalize_hostname($hostname);
                warn("Configuring SSL on global subdomains not fully supported - appending `%s' to subdomain",
                    substr($tmp, \strlen($hostname)+1));
                $hostname = $tmp;
            }
            if (!$this->letsencrypt_supported() && !$this->ssl_cert_exists()) {
                return error('SSL not enabled on account');
            }
            $certdata = $this->ssl_get_certificates();
            $cnames = [];
            if ($certdata) {
                $certdata = array_pop($certdata);
                $crt = $this->ssl_get_certificate($certdata['crt']);
                $cnames = $this->ssl_get_alternative_names($crt);
                if (\in_array($hostname, $cnames, true)) {
                    return true;
                }
                if (!$this->letsencrypt_is_ca($crt) && !\in_array('*.' . $hostname, $cnames, true)) {
                    return error("SSL certificate provided by CA other than Let's Encrypt. " .
                        "Contact issuer to add hostname `%s' to certificate or disable SSL for this web app. " .
                        "New certificate must then be installed to proceed.", $hostname);
                }
            }

            $cnames[] = $hostname;
            if (!$this->letsencrypt_request($cnames, false)) {
                [$subdomain, $domain] = array_values($this->web_split_host($hostname));
                return error(
                    "Failed to request SSL certificate for `%s'. Internal subrequest failed. Possible causes: " .
                    "(1) DNS invalid. Expected IP address `%s' for %s. Actual IP address `%s'. (2) Nameservers invalid. " .
                    "Expected nameserver settings `%s'. Actual nameserver settings `%s'. (3) DNS propagation delays. ".
                    "If this domain was recently added, it may be a propagation delay that can take up to 24 hours " .
                    "to resolve; see %s for additional details. Try again later or disable SSL.",
                    $hostname,
                    $this->common_get_ip_address(),
                    $hostname,
                    (string)$this->dns_gethostbyname_t($hostname),
                    implode(', ', $this->dns_get_hosting_nameservers($domain)),
                    implode(', ', (array)$this->dns_get_authns_from_host($hostname)),
                    MISC_KB_BASE . '/dns/dns-work/'
                );
            }
            return true;
        }

        public function theme_status(string $hostname, string $path = '', string $theme = null)
        {
            return [];
        }

        public function install_theme(string $hostname, string $path = '', string $theme, string $version = null): bool {
            return error('not implemented');
        }

        public function uninstall_theme(string $hostname, string $path = '', string $theme, bool $force = false): bool
        {
            return error('not implemented');
        }
    }