| 1: | <?php declare(strict_types=1); |
| 2: | |
| 3: | |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 11: | |
| 12: | |
| 13: | |
| 14: | |
| 15: | |
| 16: | |
| 17: | |
| 18: | |
| 19: | class Watch_Module extends Module_Skeleton |
| 20: | { |
| 21: | const CACHE_STORAGE_DURATION = 7200; |
| 22: | const CACHE_PREFIX = 'watch.'; |
| 23: | |
| 24: | public $exportedFunctions = ['*' => PRIVILEGE_SITE | PRIVILEGE_USER]; |
| 25: | |
| 26: | |
| 27: | |
| 28: | |
| 29: | |
| 30: | |
| 31: | |
| 32: | public function export($id) |
| 33: | { |
| 34: | $res = $this->fetch($id); |
| 35: | if (!$id) { |
| 36: | return error('export failed'); |
| 37: | } |
| 38: | |
| 39: | return base64_encode(serialize($res)); |
| 40: | } |
| 41: | |
| 42: | |
| 43: | |
| 44: | |
| 45: | |
| 46: | |
| 47: | |
| 48: | public function fetch($id) |
| 49: | { |
| 50: | $cache = Cache_Account::spawn($this->getAuthContext()); |
| 51: | $map = $cache->get($this->_getWatchCachePrefix() . $id); |
| 52: | if (!$map) { |
| 53: | return array(); |
| 54: | } |
| 55: | |
| 56: | return $map; |
| 57: | } |
| 58: | |
| 59: | private function _getWatchCachePrefix() |
| 60: | { |
| 61: | return self::CACHE_PREFIX; |
| 62: | } |
| 63: | |
| 64: | |
| 65: | |
| 66: | |
| 67: | |
| 68: | |
| 69: | |
| 70: | public function import($data) |
| 71: | { |
| 72: | if (!preg_match('/^[a-zA-Z0-9\+\/=]*$/', $data)) { |
| 73: | return error('data is not base64-encoded'); |
| 74: | } |
| 75: | |
| 76: | $data = \Util_PHP::unserialize(base64_decode($data)); |
| 77: | if (!$data) { |
| 78: | return error('invalid data to import'); |
| 79: | } |
| 80: | $hash = $this->_makeKeyFromResults($data); |
| 81: | $key = $this->_getWatchCachePrefix() . $hash; |
| 82: | $cache = Cache_Account::spawn($this->getAuthContext()); |
| 83: | if (!$cache->set($key, $data, self::CACHE_STORAGE_DURATION)) { |
| 84: | return error('failed to import checkpoint data: %s', |
| 85: | $cache->getResultMessage() |
| 86: | ); |
| 87: | } |
| 88: | |
| 89: | return $hash; |
| 90: | } |
| 91: | |
| 92: | private function _makeKeyFromResults($results) |
| 93: | { |
| 94: | return base_convert((string)($results['ts'] + $results['inode']), 10, 36); |
| 95: | } |
| 96: | |
| 97: | |
| 98: | |
| 99: | |
| 100: | |
| 101: | |
| 102: | |
| 103: | |
| 104: | |
| 105: | |
| 106: | public function batch($path, $id1, $mode = 'unlock') |
| 107: | { |
| 108: | $id2 = $this->checkpoint($path); |
| 109: | $diff = $this->compare($id1, $id2); |
| 110: | if (!$diff) { |
| 111: | return error('watch batch operation failed'); |
| 112: | } |
| 113: | $report = $this->_generateChangeReport($path, $diff); |
| 114: | $resp = $this->lockdown($path, $diff, $mode); |
| 115: | $report .= "\r\nEnforcement results (" . $mode . " changed files): \r\n"; |
| 116: | if (!$resp) { |
| 117: | $report .= "\tPartially succeeded. Error messages: \r\n" . |
| 118: | var_export(Error_Reporter::flush_buffer(), true); |
| 119: | } else { |
| 120: | $report .= "\tSUCCESS!"; |
| 121: | } |
| 122: | |
| 123: | Mail::send( |
| 124: | $this->common_get_admin_email(), |
| 125: | 'File Change Report (' . $this->domain . ')', |
| 126: | $report |
| 127: | ); |
| 128: | |
| 129: | return $diff; |
| 130: | } |
| 131: | |
| 132: | |
| 133: | |
| 134: | |
| 135: | |
| 136: | |
| 137: | |
| 138: | |
| 139: | |
| 140: | public function checkpoint($path) |
| 141: | { |
| 142: | $fullpath = $this->file_make_shadow_path($path); |
| 143: | if (!$fullpath) { |
| 144: | return error("unknown or invalid path `%s' provided", $path); |
| 145: | } else { |
| 146: | if (!is_dir($fullpath)) { |
| 147: | return error("path `%s' is inaccessible", $path); |
| 148: | } |
| 149: | } |
| 150: | |
| 151: | $ts = time(); |
| 152: | $inode = fileinode($fullpath); |
| 153: | $struct = array( |
| 154: | 'ts' => $ts, |
| 155: | 'path' => $path, |
| 156: | 'inode' => $inode, |
| 157: | 'map' => $this->_watch_generate($fullpath) |
| 158: | ); |
| 159: | $key = $this->_makeKeyFromResults($struct); |
| 160: | $key = $this->_getWatchCachePrefix() . $key; |
| 161: | $cache = Cache_Account::spawn($this->getAuthContext()); |
| 162: | if (is_debug()) { |
| 163: | $duration = null; |
| 164: | } else { |
| 165: | $duration = self::CACHE_STORAGE_DURATION; |
| 166: | } |
| 167: | if (!$cache->set($key, $struct, $duration)) { |
| 168: | return error('failed to save watch data: %s', |
| 169: | $cache->getResultMessage() |
| 170: | ); |
| 171: | } |
| 172: | |
| 173: | return substr($key, strlen($this->_getWatchCachePrefix())); |
| 174: | } |
| 175: | |
| 176: | |
| 177: | |
| 178: | |
| 179: | |
| 180: | |
| 181: | private function _watch_generate($path): array |
| 182: | { |
| 183: | if (!is_readable($path)) { |
| 184: | error("path `%s' is not readable by other", $this->file_unmake_shadow_path($path)); |
| 185: | |
| 186: | return array(); |
| 187: | } |
| 188: | $dh = opendir($path); |
| 189: | if (!$dh) { |
| 190: | return array(); |
| 191: | } |
| 192: | while (false !== ($file = readdir($dh))) { |
| 193: | if ($file === '..') { |
| 194: | continue; |
| 195: | } |
| 196: | $filepath = $path . '/' . $file; |
| 197: | $size = filesize($filepath); |
| 198: | $mtime = filemtime($filepath); |
| 199: | $ctime = filectime($filepath); |
| 200: | if ($file !== '.' && is_dir($filepath)) { |
| 201: | $arr[$file] = $this->_watch_generate($filepath); |
| 202: | } else { |
| 203: | $arr[$file] = array( |
| 204: | 'size' => $size, |
| 205: | 'mtime' => $mtime, |
| 206: | 'ctime' => $ctime |
| 207: | ); |
| 208: | } |
| 209: | } |
| 210: | closedir($dh); |
| 211: | |
| 212: | return $arr; |
| 213: | |
| 214: | } |
| 215: | |
| 216: | |
| 217: | |
| 218: | |
| 219: | |
| 220: | |
| 221: | |
| 222: | |
| 223: | public function compare($id1, $id2) |
| 224: | { |
| 225: | $cache = Cache_Account::spawn($this->getAuthContext()); |
| 226: | $res1 = $cache->get($this->_getWatchCachePrefix() . $id1); |
| 227: | if (false === $res1) { |
| 228: | return error("invalid or expired watch key, `%s'", $id1); |
| 229: | } |
| 230: | |
| 231: | $res2 = $cache->get($this->_getWatchCachePrefix() . $id2); |
| 232: | if (false === $res2) { |
| 233: | return error("invalid or expired watch key, `%s'", $id2); |
| 234: | } |
| 235: | if ($res1['path'] != $res2['path']) { |
| 236: | return error("path `%s' does not match path `%s'", |
| 237: | $res1['path'], |
| 238: | $res2['path'] |
| 239: | ); |
| 240: | } else if ($res1['inode'] != $res2['inode']) { |
| 241: | warn("inode mismatch on `%s' but path same, irregular results possible", $res1['path']); |
| 242: | } |
| 243: | if ($res1['ts'] > $res2['ts']) { |
| 244: | warn('tokens passed in reverse order - items shown are original values'); |
| 245: | } |
| 246: | |
| 247: | $changed = Util_PHP::array_diff_assoc_recursive($res2['map'], $res1['map']); |
| 248: | |
| 249: | return $changed; |
| 250: | } |
| 251: | |
| 252: | private function _generateChangeReport($path, $files) |
| 253: | { |
| 254: | $files = $this->_collapseChanges($path, $files); |
| 255: | $msg = 'Hello, ' . "\r\n" . |
| 256: | 'The following paths were noted as changed: ' . "\r\n\r\n"; |
| 257: | foreach ($files as $file => $modes) { |
| 258: | $msg .= "\t" . $file . ': ' . join(', ', array_keys($modes)) . "\r\n"; |
| 259: | } |
| 260: | |
| 261: | return $msg; |
| 262: | |
| 263: | } |
| 264: | |
| 265: | private function _collapseChanges($path, $files) |
| 266: | { |
| 267: | $p = $path; |
| 268: | $changed = array(); |
| 269: | foreach ($files as $f => $l) { |
| 270: | if (is_array($l)) { |
| 271: | $changed = array_merge($changed, $this->_collapseChanges($p . DIRECTORY_SEPARATOR . $f, $l)); |
| 272: | } else { |
| 273: | $changed[$p][$f] = $l; |
| 274: | } |
| 275: | |
| 276: | } |
| 277: | |
| 278: | return $changed; |
| 279: | } |
| 280: | |
| 281: | |
| 282: | |
| 283: | |
| 284: | |
| 285: | |
| 286: | |
| 287: | |
| 288: | |
| 289: | public function lockdown($path, $diff, $mode = 'unlock') |
| 290: | { |
| 291: | if (!IS_CLI) { |
| 292: | return $this->query('watch_lockdown', $path, $diff); |
| 293: | } |
| 294: | |
| 295: | if (!$this->file_exists($path)) { |
| 296: | return error("path `%s' does not exist", $path); |
| 297: | } |
| 298: | $stat = $this->file_stat($path); |
| 299: | $uid = $stat['uid']; |
| 300: | if ($stat['uid'] < User_Module::MIN_UID && $stat['owner'] !== $this->web_get_sys_user()) { |
| 301: | return error("uid of `%s' is a system uid `%d'", $path, $stat['uid']); |
| 302: | } else if (($this->permission_level & PRIVILEGE_USER) && $uid !== $this->user_id) { |
| 303: | return error('cannot lockdown docroots unowned by this user'); |
| 304: | } |
| 305: | $username = $this->user_get_username_from_uid($uid); |
| 306: | $proposed = $this->_collapseChanges($path, $diff); |
| 307: | |
| 308: | $adjfiles = array(); |
| 309: | $adjdirs = array(); |
| 310: | foreach ($proposed as $f => $meta) { |
| 311: | if (isset($meta['size'])) { |
| 312: | |
| 313: | $adjfiles[$f] = true; |
| 314: | } else if (isset($meta['ctime'])) { |
| 315: | |
| 316: | $dir = dirname($f); |
| 317: | $adjdirs[$dir] = true; |
| 318: | } else if (substr($f, -1) === '.') { |
| 319: | |
| 320: | |
| 321: | $adjdirs[dirname($f)] = true; |
| 322: | } else { |
| 323: | |
| 324: | $adjfiles[$f] = true; |
| 325: | } |
| 326: | } |
| 327: | $filtered = array_filter( |
| 328: | array_merge(array_keys($adjdirs), array_keys($adjfiles)), |
| 329: | static function ($d) use ($path) { |
| 330: | return 0 === strpos($d, $path); |
| 331: | } |
| 332: | ); |
| 333: | |
| 334: | if ($mode === 'lock') { |
| 335: | $this->file_chown($filtered, $username); |
| 336: | |
| 337: | return $this->file_set_acls($filtered, null); |
| 338: | } |
| 339: | |
| 340: | |
| 341: | $this->file_chown($path, $username, true); |
| 342: | if (!$this->file_set_acls($path, null, |
| 343: | array(File_Module::ACL_MODE_RECURSIVE)) |
| 344: | ) { |
| 345: | warn("failed to release apache acls on `%s'", $path); |
| 346: | } |
| 347: | |
| 348: | $prefix = $this->domain_shadow_path(); |
| 349: | foreach ($filtered as $f) { |
| 350: | $f = $prefix . $f; |
| 351: | if (file_exists($f) && filegroup($f) === APACHE_GID) { |
| 352: | chgrp($f, $this->group_id); |
| 353: | } |
| 354: | } |
| 355: | |
| 356: | $filteredFiles = array_filter($filtered, |
| 357: | static function ($f) { |
| 358: | return substr($f, -2) !== '/.'; |
| 359: | }); |
| 360: | $filteredDirs = array_diff($filtered, $filteredFiles); |
| 361: | |
| 362: | $webuser = $this->web_get_user($path); |
| 363: | |
| 364: | $users = array( |
| 365: | [$webuser => 7], |
| 366: | [$username => 7], |
| 367: | ); |
| 368: | $ret = $this->file_set_acls($filteredFiles, $users); |
| 369: | if ($ret && $filteredDirs) { |
| 370: | |
| 371: | |
| 372: | $users = array_merge($users, [ |
| 373: | [$webuser => 'drwx'], |
| 374: | [$username => 'drwx'] |
| 375: | ]); |
| 376: | $ret &= $this->file_set_acls($filteredDirs, $users); |
| 377: | } |
| 378: | |
| 379: | return $ret; |
| 380: | |
| 381: | } |
| 382: | } |
| 383: | |