1: <?php declare(strict_types=1);
2: /**
3: * +------------------------------------------------------------+
4: * | apnscp |
5: * +------------------------------------------------------------+
6: * | Copyright (c) Apis Networks |
7: * +------------------------------------------------------------+
8: * | Licensed under Artistic License 2.0 |
9: * +------------------------------------------------------------+
10: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
11: * +------------------------------------------------------------+
12: */
13:
14: /**
15: * File watch component
16: *
17: * @package core
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: * Export a checkpoint
28: *
29: * @param string $id checkpoint ID to export @link checkpoint
30: * @return bool|string
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: * Retrieve stored checkpoint from cache
44: *
45: * @param string $id
46: * @return array
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: * Import a saved checkpoint
66: *
67: * @param string $data checkpoint data (@see export)
68: * @return bool
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: * Unattended file change calcuation
99: *
100: * @param $path
101: * @param $id1 initial reference token (@see watch)
102: * @param string $mode whether to lock or unlock changed files
103: * @return bool
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: * Make a filesystem checkpoint
134: *
135: * Note: this only works on publicly readable locations
136: *
137: * @param string $path path to checkpoint
138: * @return string checkpoint id
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: * @param string $path resolved shadow path
179: * @return array
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: * Compare checkpoints for changes
218: *
219: * @param string $id1 initial checkpoint
220: * @param string $id2 comparison checkpoint
221: * @return array|bool differences or false on failure
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: // files that have changed
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: * Change ownership to active user + open up only to $diff files
283: *
284: * @param string $path
285: * @param array $diff calculated diff @see compare()
286: * @param string $mode lock or unlock, how to handle changed files
287: * @return bool
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: // files and directories to adjust
308: $adjfiles = array();
309: $adjdirs = array();
310: foreach ($proposed as $f => $meta) {
311: if (isset($meta['size'])) {
312: // file grew
313: $adjfiles[$f] = true;
314: } else if (isset($meta['ctime'])) {
315: // file created
316: $dir = dirname($f);
317: $adjdirs[$dir] = true;
318: } else if (substr($f, -1) === '.') {
319: // mtime
320: // file removed or added
321: $adjdirs[dirname($f)] = true;
322: } else {
323: // file modified in place
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: // unlocked
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: // make sure apache-created files are turned over to the account
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: // setfacl yelps if [d]efault flag applied and file is not a directory
371: // "Only directories can have default ACLs" is translated, so two 2 rounds
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: