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: 
<?php declare(strict_types=1);
    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    /**
     * File watch component
     *
     * @package core
     */
    class Watch_Module extends Module_Skeleton
    {
        const CACHE_STORAGE_DURATION = 7200;
        const CACHE_PREFIX = 'watch.';

        public $exportedFunctions = ['*' => PRIVILEGE_SITE | PRIVILEGE_USER];

        /**
         * Export a checkpoint
         *
         * @param string $id checkpoint ID to export @link checkpoint
         * @return bool|string
         */
        public function export($id)
        {
            $res = $this->fetch($id);
            if (!$id) {
                return error('export failed');
            }

            return base64_encode(serialize($res));
        }

        /**
         * Retrieve stored checkpoint from cache
         *
         * @param string $id
         * @return array
         */
        public function fetch($id)
        {
            $cache = Cache_Account::spawn($this->getAuthContext());
            $map = $cache->get($this->_getWatchCachePrefix() . $id);
            if (!$map) {
                return array();
            }

            return $map;
        }

        private function _getWatchCachePrefix()
        {
            return self::CACHE_PREFIX;
        }

        /**
         * Import a saved checkpoint
         *
         * @param string $data checkpoint data (@see export)
         * @return bool
         */
        public function import($data)
        {
            if (!preg_match('/^[a-zA-Z0-9\+\/=]*$/', $data)) {
                return error('data is not base64-encoded');
            }

            $data = \Util_PHP::unserialize(base64_decode($data));
            if (!$data) {
                return error('invalid data to import');
            }
            $hash = $this->_makeKeyFromResults($data);
            $key = $this->_getWatchCachePrefix() . $hash;
            $cache = Cache_Account::spawn($this->getAuthContext());
            if (!$cache->set($key, $data, self::CACHE_STORAGE_DURATION)) {
                return error('failed to import checkpoint data: %s',
                    $cache->getResultMessage()
                );
            }

            return $hash;
        }

        private function _makeKeyFromResults($results)
        {
            return base_convert($results['ts'] + $results['inode'], 10, 36);
        }

        /**
         * Unattended file change calcuation
         *
         * @param        $path
         * @param        $id1  initial reference token (@see watch)
         * @param string $mode whether to lock or unlock changed files
         * @return bool
         *
         */
        public function batch($path, $id1, $mode = 'unlock')
        {
            $id2 = $this->checkpoint($path);
            $diff = $this->compare($id1, $id2);
            if (!$diff) {
                return error('watch batch operation failed');
            }
            $report = $this->_generateChangeReport($path, $diff);
            $resp = $this->lockdown($path, $diff, $mode);
            $report .= "\r\nEnforcement results (" . $mode . " changed files): \r\n";
            if (!$resp) {
                $report .= "\tPartially succeeded. Error messages: \r\n" .
                    var_export(Error_Reporter::flush_buffer(), true);
            } else {
                $report .= "\tSUCCESS!";
            }

            Mail::send(
                $this->common_get_admin_email(),
                'File Change Report (' . $this->domain . ')',
                $report
            );

            return $diff;
        }

        /**
         * Make a filesystem checkpoint
         *
         * Note: this only works on publicly readable locations
         *
         * @param string $path path to checkpoint
         * @return string checkpoint id
         */
        public function checkpoint($path)
        {
            $fullpath = $this->file_make_shadow_path($path);
            if (!$fullpath) {
                return error("unknown or invalid path `%s' provided", $path);
            } else {
                if (!is_dir($fullpath)) {
                    return error("path `%s' is inaccessible", $path);
                }
            }

            $ts = time();
            $inode = fileinode($fullpath);
            $struct = array(
                'ts'    => $ts,
                'path'  => $path,
                'inode' => $inode,
                'map'   => $this->_watch_generate($fullpath)
            );
            $key = $this->_makeKeyFromResults($struct);
            $key = $this->_getWatchCachePrefix() . $key;
            $cache = Cache_Account::spawn($this->getAuthContext());
            if (is_debug()) {
                $duration = null;
            } else {
                $duration = self::CACHE_STORAGE_DURATION;
            }
            if (!$cache->set($key, $struct, $duration)) {
                return error('failed to save watch data: %s',
                    $cache->getResultMessage()
                );
            }

            return substr($key, strlen($this->_getWatchCachePrefix()));
        }

        /**
         *
         * @param string $path resolved shadow path
         * @return array
         */
        private function _watch_generate($path): array
        {
            if (!is_readable($path)) {
                error("path `%s' is not readable by other", $this->file_unmake_shadow_path($path));

                return array();
            }
            $dh = opendir($path);
            if (!$dh) {
                return array();
            }
            while (false !== ($file = readdir($dh))) {
                if ($file === '..') {
                    continue;
                }
                $filepath = $path . '/' . $file;
                $size = filesize($filepath);
                $mtime = filemtime($filepath);
                $ctime = filectime($filepath);
                if ($file !== '.' && is_dir($filepath)) {
                    $arr[$file] = $this->_watch_generate($filepath);
                } else {
                    $arr[$file] = array(
                        'size'  => $size,
                        'mtime' => $mtime,
                        'ctime' => $ctime
                    );
                }
            }
            closedir($dh);

            return $arr;

        }

        /**
         * Compare checkpoints for changes
         *
         * @param string $id1 initial checkpoint
         * @param string $id2 comparison checkpoint
         * @return array|bool differences or false on failure
         */
        public function compare($id1, $id2)
        {
            $cache = Cache_Account::spawn($this->getAuthContext());
            $res1 = $cache->get($this->_getWatchCachePrefix() . $id1);
            if (false === $res1) {
                return error("invalid or expired watch key, `%s'", $id1);
            }

            $res2 = $cache->get($this->_getWatchCachePrefix() . $id2);
            if (false === $res2) {
                return error("invalid or expired watch key, `%s'", $id2);
            }
            if ($res1['path'] != $res2['path']) {
                return error("path `%s' does not match path `%s'",
                    $res1['path'],
                    $res2['path']
                );
            } else if ($res1['inode'] != $res2['inode']) {
                warn("inode mismatch on `%s' but path same, irregular results possible", $res1['path']);
            }
            if ($res1['ts'] > $res2['ts']) {
                warn('tokens passed in reverse order - items shown are original values');
            }
            // files that have changed
            $changed = Util_PHP::array_diff_assoc_recursive($res2['map'], $res1['map']);

            return $changed;
        }

        private function _generateChangeReport($path, $files)
        {
            $files = $this->_collapseChanges($path, $files);
            $msg = 'Hello, ' . "\r\n" .
                'The following paths were noted as changed: ' . "\r\n\r\n";
            foreach ($files as $file => $modes) {
                $msg .= "\t" . $file . ': ' . join(', ', array_keys($modes)) . "\r\n";
            }

            return $msg;

        }

        private function _collapseChanges($path, $files)
        {
            $p = $path;
            $changed = array();
            foreach ($files as $f => $l) {
                if (is_array($l)) {
                    $changed = array_merge($changed, $this->_collapseChanges($p . DIRECTORY_SEPARATOR . $f, $l));
                } else {
                    $changed[$p][$f] = $l;
                }

            }

            return $changed;
        }

        /**
         * Change ownership to active user + open up only to $diff files
         *
         * @param string $path
         * @param array  $diff calculated diff @see compare()
         * @param string $mode lock or unlock, how to handle changed files
         * @return bool
         */
        public function lockdown($path, $diff, $mode = 'unlock')
        {
            if (!IS_CLI) {
                return $this->query('watch_lockdown', $path, $diff);
            }

            if (!$this->file_exists($path)) {
                return error("path `%s' does not exist", $path);
            }
            $stat = $this->file_stat($path);
            $uid = $stat['uid'];
            if ($stat['uid'] < User_Module::MIN_UID && $stat['owner'] !== $this->web_get_sys_user()) {
                return error("uid of `%s' is a system uid `%d'", $path, $stat['uid']);
            } else if (($this->permission_level & PRIVILEGE_USER) && $uid !== $this->user_id) {
                return error('cannot lockdown docroots unowned by this user');
            }
            $username = $this->user_get_username_from_uid($uid);
            $proposed = $this->_collapseChanges($path, $diff);
            // files and directories to adjust
            $adjfiles = array();
            $adjdirs = array();
            foreach ($proposed as $f => $meta) {
                if (isset($meta['size'])) {
                    // file grew
                    $adjfiles[$f] = true;
                } else if (isset($meta['ctime'])) {
                    // file created
                    $dir = dirname($f);
                    $adjdirs[$dir] = true;
                } else if (substr($f, -1) === '.') {
                    // mtime
                    // file removed or added
                    $adjdirs[dirname($f)] = true;
                } else {
                    // file modified in place
                    $adjfiles[$f] = true;
                }
            }
            $filtered = array_filter(
                array_merge(array_keys($adjdirs), array_keys($adjfiles)),
                static function ($d) use ($path) {
                    return 0 === strpos($d, $path);
                }
            );

            if ($mode === 'lock') {
                $this->file_chown($filtered, $username);

                return $this->file_set_acls($filtered, null);
            }

            // unlocked
            $this->file_chown($path, $username, true);
            if (!$this->file_set_acls($path, null,
                array(File_Module::ACL_MODE_RECURSIVE))
            ) {
                warn("failed to release apache acls on `%s'", $path);
            }
            // make sure apache-created files are turned over to the account
            $prefix = $this->domain_shadow_path();
            foreach ($filtered as $f) {
                $f = $prefix . $f;
                if (file_exists($f) && filegroup($f) === APACHE_GID) {
                    chgrp($f, $this->group_id);
                }
            }

            $filteredFiles = array_filter($filtered,
                static function ($f) {
                    return substr($f, -2) !== '/.';
                });
            $filteredDirs = array_diff($filtered, $filteredFiles);

            $webuser = $this->web_get_user($path);

            $users = array(
                [$webuser  => 7],
                [$username => 7],
            );
            $ret = $this->file_set_acls($filteredFiles, $users);
            if ($ret && $filteredDirs) {
                // setfacl yelps if [d]efault flag applied and file is not a directory
                // "Only directories can have default ACLs" is translated, so two 2 rounds
                $users = array_merge($users, [
                    [$webuser  => 'drwx'],
                    [$username => 'drwx']
                ]);
                $ret &= $this->file_set_acls($filteredDirs, $users);
            }

            return $ret;

        }
    }