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: | |