PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
fluent-boards / app / Services / Libs / FileSystem.php

FileSystem.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.0, at app/Services/Libs/FileSystem.php

426 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services\Libs;
4
5 use FluentBoards\Framework\Support\Arr;
6 use FluentBoards\App\Services\Constant;
7 use Exception;
8
9 class FileSystem
10 {
11 protected $subDir = '';
12
13 public function _setSubDir($subDir)
14 {
15 $this->subDir = $subDir;
16 return $this;
17 }
18 /**
19 * Read file content from custom upload dir of this application
20 * @return string [path]
21 */
22 public function _get($file)
23 {
24 $arr = explode('/', $file);
25 $fileName = end($arr);
26 if ($this->subDir) {
27 $fileName = $this->subDir . DIRECTORY_SEPARATOR . $fileName;
28 }
29
30 $filePath = $this->getDir() . DIRECTORY_SEPARATOR . $fileName;
31
32 // Use WordPress Filesystem API
33 global $wp_filesystem;
34 if (!function_exists('WP_Filesystem')) {
35 require_once(ABSPATH . 'wp-admin/includes/file.php');
36 }
37 WP_Filesystem();
38
39 return $wp_filesystem->get_contents($filePath);
40 }
41
42 /**
43 * Get custom upload dir name of this application
44 * @return string [directory path]
45 */
46 public function _getDir()
47 {
48 $uploadDir = wp_upload_dir();
49
50 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
51
52 if ($this->subDir) {
53 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir. DIRECTORY_SEPARATOR. $this->subDir;
54 }
55
56 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
57 }
58
59 /**
60 * Prepare local storage with Apache rules or verified host-managed protection.
61 * Installing rules alone does not prove the HTTP server enforces them.
62 * Pass false to defer any required HTTP verification to cron.
63 */
64 public function _installUploadProtectionRules($allowProbe = true)
65 {
66 // Protect the root even when called on a board-specific filesystem instance.
67 $directory = (new static())->_getDir();
68 if (!wp_mkdir_p($directory)) {
69 return false;
70 }
71
72 $path = $directory . '/.htaccess';
73 $rules = [
74 '<IfModule mod_authz_core.c>',
75 'Require all denied',
76 '</IfModule>',
77 '<IfModule !mod_authz_core.c>',
78 'Order deny,allow',
79 'Deny from all',
80 '</IfModule>',
81 ];
82
83 // Hosts may lock security configuration while leaving upload storage writable.
84 if (is_file($path) && is_readable($path)) {
85 $contents = str_replace("\r\n", "\n", file_get_contents($path));
86 if (preg_match_all('/^# BEGIN Fluent Boards\n(.*?)^# END Fluent Boards$/ms', $contents, $blocks) === 1) {
87 $existingRules = array_values(array_filter(array_map('trim', explode("\n", $blocks[1][0])), function ($line) {
88 return $line !== '' && strpos($line, '#') !== 0;
89 }));
90 if ($existingRules === $rules) {
91 return true;
92 }
93 }
94 }
95
96 if (!function_exists('insert_with_markers')) {
97 require_once ABSPATH . 'wp-admin/includes/misc.php';
98 }
99
100 if (insert_with_markers($path, 'Fluent Boards', $rules)) {
101 return true;
102 }
103
104 // Unmarked, locked host rules and server-level restrictions need no rewrite.
105 return $this->_checkUploadProtection($allowProbe) === true;
106 }
107
108 /**
109 * Probe direct access without login cookies using a harmless temporary file.
110 * Return true for denied access, false for exposure, or null when unverified.
111 * Pass false to read only the cache and schedule a missing check in cron.
112 */
113 public function _checkUploadProtection($allowProbe = true)
114 {
115 $directory = (new static())->_getDir();
116 $uploads = wp_upload_dir();
117 $folder = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
118 $url = trailingslashit($uploads['baseurl']) . trim($folder, '/');
119 $rulesPath = $directory . '/.htaccess';
120 $rulesHash = is_file($rulesPath) && is_readable($rulesPath) ? hash_file('sha256', $rulesPath) : '';
121 $cacheKey = Constant::UPLOAD_PROTECTION_CHECK_PREFIX . md5($directory . '|' . $url);
122 $cached = get_transient($cacheKey);
123 if (is_array($cached) && isset($cached['rules_hash']) && $cached['rules_hash'] === $rulesHash && array_key_exists('blocked', $cached)) {
124 return $cached['blocked'];
125 }
126
127 if (!$allowProbe) {
128 $hook = 'fluent_boards/check_upload_protection';
129 if (!wp_next_scheduled($hook)) {
130 wp_schedule_single_event(time(), $hook);
131 }
132 return null;
133 }
134
135 $blocked = null;
136 $filename = 'fbs-access-check-' . wp_generate_uuid4() . '.txt';
137 $path = trailingslashit($directory) . $filename;
138 $contents = wp_generate_uuid4();
139 try {
140 if (is_dir($directory) && is_writable($directory)
141 && file_put_contents($path, $contents, LOCK_EX) === strlen($contents)) {
142 // The upload URL comes from trusted site configuration, never request input.
143 $response = wp_remote_get($url . '/' . $filename, [
144 'timeout' => 5,
145 'redirection' => 0,
146 'limit_response_size' => 1024,
147 'headers' => ['Cache-Control' => 'no-cache'],
148 ]);
149 if (!is_wp_error($response)) {
150 $status = wp_remote_retrieve_response_code($response);
151 if (in_array($status, [403, 404], true)) {
152 $blocked = true;
153 } elseif ($status === 200 && wp_remote_retrieve_body($response) === $contents) {
154 $blocked = false;
155 }
156 }
157 }
158 } finally {
159 if (is_file($path)) {
160 wp_delete_file($path);
161 }
162 }
163
164 set_transient($cacheKey, ['rules_hash' => $rulesHash, 'blocked' => $blocked], 5 * MINUTE_IN_SECONDS);
165 return $blocked;
166 }
167
168 /**
169 * Get absolute path of file using custom upload dir name of this application
170 * @return string [file path]
171 */
172 public function _getAbsolutePathOfFile($file)
173 {
174 return $this->_getDir() . DIRECTORY_SEPARATOR . $file;
175 }
176
177 /**
178 * Resolve a stored attachment path only when it belongs to Fluent Boards uploads.
179 *
180 * @param string $storedPath
181 * @param int|null $boardId
182 * @return string|null
183 */
184 public function _resolveLocalAttachmentPath($storedPath, $boardId = null)
185 {
186 if (!$storedPath) {
187 return null;
188 }
189
190 $storedFilename = rawurldecode((string) $storedPath);
191 $isBareFilename = $storedFilename !== ''
192 && strpos($storedFilename, '/') === false
193 && strpos($storedFilename, '\\') === false;
194
195 if ($isBareFilename && $boardId === null) {
196 return null;
197 }
198
199 $filePath = $isBareFilename ? null : realpath($storedPath);
200 $allowedDirectory = $this->_getDir();
201 $pluginRoot = realpath($allowedDirectory);
202
203 if (!$pluginRoot) {
204 return null;
205 }
206
207 $pluginRoot = rtrim($pluginRoot, DIRECTORY_SEPARATOR);
208 $allowedRoot = $pluginRoot;
209
210 if ($boardId !== null) {
211 if (!is_int($boardId) && !is_string($boardId)) {
212 return null;
213 }
214
215 $boardId = filter_var($boardId, FILTER_VALIDATE_INT, [
216 'options' => ['min_range' => 1],
217 ]);
218 if ($boardId === false) {
219 return null;
220 }
221
222 $allowedDirectory .= DIRECTORY_SEPARATOR . 'board_' . $boardId;
223 if (is_link($allowedDirectory)) {
224 return null;
225 }
226
227 $allowedRoot = realpath($allowedDirectory);
228 $expectedBoardRoot = $pluginRoot . DIRECTORY_SEPARATOR . 'board_' . $boardId;
229 if (!$allowedRoot || $allowedRoot !== $expectedBoardRoot) {
230 return null;
231 }
232 }
233
234 if ($isBareFilename) {
235 $filePath = realpath($allowedDirectory . DIRECTORY_SEPARATOR . $storedFilename);
236 }
237
238 if (!$filePath || !$allowedRoot || !is_file($filePath)) {
239 return null;
240 }
241
242 $allowedRoot = rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
243
244 return strpos($filePath, $allowedRoot) === 0 ? $filePath : null;
245 }
246
247 /**
248 * Upload request files into the application's custom upload directory.
249 * @return array
250 */
251 public function _uploadFromRequest()
252 {
253 return $this->_put(FluentBoards('request')->files());
254 }
255
256 /**
257 * Prepare upload-directory protection before accepting local files.
258 * @param array $files
259 * @return array
260 */
261 public function _put($files)
262 {
263 if (!$this->_installUploadProtectionRules()) {
264 throw new Exception(__('Could not prepare upload-directory protection. Please ask your hosting provider to block direct file access and check directory permissions.', 'fluent-boards'));
265 }
266
267 if (!function_exists('wp_handle_upload')) {
268 require_once(ABSPATH . 'wp-admin/includes/file.php');
269 }
270
271 $this->overrideUploadDir();
272
273 $uploadOverrides = [
274 'test_form' => false,
275 // Accept the same allow-list FluentBoards validates against, so wp_handle_upload
276 // doesn't reject broader types (e.g. .json, .md) by extension.
277 'mimes' => \FluentBoards\App\Services\UploadService::getAllowedMimeMap(),
278 ];
279 $uploadedFiles = []; // Initialize the array
280
281 if(is_object($files)) {
282 $files = [$files];
283 }
284
285 foreach ((array)$files as $file) {
286
287 $filesArray = $file->toArray();
288
289 // tmp_name is the path to the uploaded file which is required for wp_handle_upload, new framework update
290 // changed the way to get the tmp_name Reference to line # 448 in File.php
291 $filesArray['tmp_name'] = $file->getRealPath();
292
293 $extraData = Arr::only($filesArray, ['name', 'size']);
294 $uploadsData = \wp_handle_upload($filesArray, $uploadOverrides);
295 // Add the full path to the file
296 $uploadsData['full_path'] = $this->_getAbsolutePathOfFile($uploadsData['file']);
297 $uploadedFiles[] = array_merge($extraData, $uploadsData);
298 }
299
300 return $uploadedFiles;
301 }
302
303 /**
304 * Delete a file from custom upload directory of this application
305 * @param array $files
306 * @return void
307 */
308 public function _delete($files)
309 {
310 $files = (array)$files;
311
312 foreach ($files as $file) {
313 $arr = explode('/', $file);
314 $fileName = end($arr);
315 $filePath = $this->getDir() . '/' . $fileName;
316 if (file_exists($filePath)) {
317 wp_delete_file($filePath);
318 }
319 }
320 }
321
322 /**
323 * Register filters for custom upload dir
324 */
325 public function _overrideUploadDir()
326 {
327 add_filter('wp_handle_upload_prefilter', function ($file) {
328 add_filter('upload_dir', [$this, '_setCustomUploadDir']);
329
330 add_filter('wp_handle_upload', function ($fileinfo) {
331 remove_filter('upload_dir', [$this, '_setCustomUploadDir']);
332 $fileinfo['file'] = basename($fileinfo['file']);
333 return $fileinfo;
334 });
335
336 return $this->_renameFileName($file);
337 });
338 }
339
340 /**
341 * Set plugin's custom upload dir
342 * @param array $param
343 * @return array $param
344 */
345 public function _setCustomUploadDir($param)
346 {
347 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
348
349 if ($this->subDir) {
350 $fbsUploadDir .= DIRECTORY_SEPARATOR . $this->subDir;
351 if (!is_dir($param['basedir'] . $fbsUploadDir)) {
352 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Creating custom upload subdirectory during upload process, WP_Filesystem not initialized
353 @mkdir($param['basedir'] . $fbsUploadDir, 0755);
354 }
355 }
356
357 $param['url'] = $param['baseurl'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
358
359 $param['path'] = $param['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
360
361 return $param;
362 }
363
364 /**
365 * Rename the uploaded file name before saving
366 * @param array $file
367 * @return array $file
368 */
369 public function _renameFileName($file)
370 {
371 $prefix = wp_generate_uuid4() . '-';
372 $prefix = apply_filters('fluent_boards/uploaded_file_name_prefix', $prefix);
373 $file['name'] = $prefix . $file['name'];
374
375 return $file;
376 }
377
378 public function _deleteDir($dir)
379 {
380 $dir = $this->getDir() . DIRECTORY_SEPARATOR . $dir;
381 if (is_dir($dir)) {
382 $this->deleteContents($dir);
383 }
384 }
385
386 /**
387 * Delete a directory and its content
388 * Lead Note: this method should be called via scheduler or queue
389 * @param string $dir
390 * @return bool
391 */
392 private function deleteContents($dir)
393 {
394 $files = array_diff(scandir($dir), ['.', '..']);
395 foreach ($files as $file) {
396 if(is_dir("$dir/$file")) {
397 // Recursively delete subdirectory
398 $this->deleteContents("$dir/$file");
399 } else {
400 // Delete file
401 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Recursive directory cleanup, direct file operations required
402 unlink("$dir/$file");
403 }
404 }
405 // Delete the directory itself. If the directory is not empty, it will not be deleted.
406 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Recursive directory removal, WP_Filesystem not suitable for this operation
407 return rmdir($dir);
408 }
409
410 public static function __callStatic($method, $params)
411 {
412 $instance = new static;
413
414 return call_user_func_array([$instance, $method], $params);
415 }
416
417 public function __call($method, $params)
418 {
419 $hiddenMethod = "_" . $method;
420
421 $method = method_exists($this, $hiddenMethod) ? $hiddenMethod : $method;
422
423 return call_user_func_array([$this, $method], $params);
424 }
425 }
426