PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.1
2.2.1 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 All 44 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.1, at app/Services/Libs/FileSystem.php

373 lines 12.0 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\UploadService;
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
27 $filePath = $this->getDir() . DIRECTORY_SEPARATOR . $fileName;
28
29 // Use WordPress Filesystem API
30 global $wp_filesystem;
31 if (!function_exists('WP_Filesystem')) {
32 require_once(ABSPATH . 'wp-admin/includes/file.php');
33 }
34 WP_Filesystem();
35
36 return $wp_filesystem->get_contents($filePath);
37 }
38
39 /**
40 * Get custom upload dir name of this application
41 * @return string [directory path]
42 */
43 public function _getDir()
44 {
45 $uploadDir = wp_upload_dir();
46
47 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
48
49 if ($this->subDir) {
50 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir. DIRECTORY_SEPARATOR. $this->subDir;
51 }
52
53 return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
54 }
55
56 /**
57 * Legacy Core/Pro entry point: prepare storage, not a verified security boundary.
58 * Rule writes are best effort; $allowProbe is retained for caller compatibility.
59 */
60 public function _installUploadProtectionRules($allowProbe = true)
61 {
62 $directory = (new static())->_getDir();
63 if (!wp_mkdir_p($directory)) {
64 return false;
65 }
66 $path = $directory . '/.htaccess';
67 if (!$this->hasUploadProtectionRules($path)) {
68 if (!function_exists('insert_with_markers')) {
69 require_once ABSPATH . 'wp-admin/includes/misc.php';
70 }
71 insert_with_markers($path, 'Fluent Boards', $this->uploadProtectionRules());
72 }
73 return true;
74 }
75
76 protected function uploadProtectionRules()
77 {
78 return [
79 '<IfModule mod_authz_core.c>',
80 'Require all denied',
81 '</IfModule>',
82 '<IfModule !mod_authz_core.c>',
83 'Order deny,allow',
84 'Deny from all',
85 '</IfModule>',
86 ];
87 }
88
89 protected function hasUploadProtectionRules($path)
90 {
91 if (!is_file($path) || !is_readable($path)) {
92 return false;
93 }
94 $contents = str_replace("\r\n", "\n", file_get_contents($path));
95 if (preg_match_all('/^# BEGIN Fluent Boards\n(.*?)^# END Fluent Boards$/ms', $contents, $blocks) !== 1) {
96 return false;
97 }
98 $rules = array_values(array_filter(array_map('trim', explode("\n", $blocks[1][0])), function ($line) {
99 return $line !== '' && strpos($line, '#') !== 0;
100 }));
101 return $rules === $this->uploadProtectionRules();
102 }
103
104 /** Legacy adapter for older Pro callers; protection is no longer probed over HTTP. */
105 public function _checkUploadProtection($allowProbe = true)
106 {
107 return null;
108 }
109
110 /**
111 * Get absolute path of file using custom upload dir name of this application
112 * @return string [file path]
113 */
114 public function _getAbsolutePathOfFile($file)
115 {
116 return $this->_getDir() . DIRECTORY_SEPARATOR . $file;
117 }
118
119 /**
120 * Resolve a stored attachment path only when it belongs to Fluent Boards uploads.
121 *
122 * @param string $storedPath
123 * @param int|null $boardId
124 * @return string|null
125 */
126 public function _resolveLocalAttachmentPath($storedPath, $boardId = null)
127 {
128 if (!$storedPath) {
129 return null;
130 }
131
132 $storedFilename = rawurldecode((string) $storedPath);
133 $isBareFilename = $storedFilename !== ''
134 && strpos($storedFilename, '/') === false
135 && strpos($storedFilename, '\\') === false;
136
137 if ($isBareFilename && $boardId === null) {
138 return null;
139 }
140
141 $filePath = $isBareFilename ? null : realpath($storedPath);
142 $allowedDirectory = $this->_getDir();
143 $pluginRoot = realpath($allowedDirectory);
144
145 if (!$pluginRoot) {
146 return null;
147 }
148
149 $pluginRoot = rtrim($pluginRoot, DIRECTORY_SEPARATOR);
150 $allowedRoot = $pluginRoot;
151
152 if ($boardId !== null) {
153 if (!is_int($boardId) && !is_string($boardId)) {
154 return null;
155 }
156
157 $boardId = filter_var($boardId, FILTER_VALIDATE_INT, [
158 'options' => ['min_range' => 1],
159 ]);
160 if ($boardId === false) {
161 return null;
162 }
163
164 $allowedDirectory .= DIRECTORY_SEPARATOR . 'board_' . $boardId;
165 if (is_link($allowedDirectory)) {
166 return null;
167 }
168
169 $allowedRoot = realpath($allowedDirectory);
170 $expectedBoardRoot = $pluginRoot . DIRECTORY_SEPARATOR . 'board_' . $boardId;
171 if (!$allowedRoot || $allowedRoot !== $expectedBoardRoot) {
172 return null;
173 }
174 }
175
176 if ($isBareFilename) {
177 $filePath = realpath($allowedDirectory . DIRECTORY_SEPARATOR . $storedFilename);
178 }
179
180 if (!$filePath || !$allowedRoot || !is_file($filePath)) {
181 return null;
182 }
183
184 $allowedRoot = rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
185
186 return strpos($filePath, $allowedRoot) === 0 ? $filePath : null;
187 }
188
189 /**
190 * Upload request files into the application's custom upload directory.
191 * @return array
192 */
193 public function _uploadFromRequest()
194 {
195 return $this->_put(FluentBoards('request')->files());
196 }
197
198 /**
199 * Prepare upload-directory protection before accepting local files.
200 * @param array $files
201 * @return array
202 */
203 public function _put($files)
204 {
205 if (!$this->_installUploadProtectionRules()) {
206 throw new Exception(__('Could not create the upload directory. Please check directory permissions.', 'fluent-boards'));
207 }
208
209 if (!function_exists('wp_handle_upload')) {
210 require_once(ABSPATH . 'wp-admin/includes/file.php');
211 }
212
213 $uploadOverrides = [
214 'test_form' => false,
215 'mimes' => UploadService::getAllowedMimeMap(),
216 ];
217 $uploadedFiles = [];
218 $files = is_object($files) ? [$files] : (array) $files;
219
220 $this->_overrideUploadDir();
221 try {
222 foreach ($files as $file) {
223 $filesArray = $file->toArray();
224 $filesArray['tmp_name'] = $file->getRealPath();
225 $extraData = Arr::only($filesArray, ['name', 'size']);
226 if (empty($filesArray['error']) && is_file($filesArray['tmp_name'])) {
227 $filesArray['size'] = filesize($filesArray['tmp_name']);
228 $filesArray['size_in_bytes'] = $filesArray['size'];
229 (new UploadService())->validateFile($filesArray);
230 }
231 $uploadsData = wp_handle_upload($filesArray, $uploadOverrides);
232 if (!empty($uploadsData['error']) || empty($uploadsData['file'])) {
233 throw new Exception($uploadsData['error'] ?? __('File upload failed.', 'fluent-boards'));
234 }
235
236 // Keep WordPress's actual path; callers also expect a legacy bare filename.
237 $uploadsData['full_path'] = $uploadsData['file'];
238 $uploadsData['file'] = basename($uploadsData['file']);
239 $uploadedFiles[] = array_merge($extraData, $uploadsData);
240 }
241 } catch (Exception $exception) {
242 // The batch fails as a whole, so remove files this call already stored.
243 foreach ($uploadedFiles as $uploadedFile) {
244 wp_delete_file($uploadedFile['full_path']);
245 }
246 throw $exception;
247 } finally {
248 remove_filter('wp_handle_upload_prefilter', [$this, '_renameFileName']);
249 remove_filter('upload_dir', [$this, '_setCustomUploadDir']);
250 }
251
252 return $uploadedFiles;
253 }
254
255 /**
256 * Delete a file from custom upload directory of this application
257 * @param array $files
258 * @return void
259 */
260 public function _delete($files)
261 {
262 $files = (array)$files;
263
264 foreach ($files as $file) {
265 $arr = explode('/', $file);
266 $fileName = end($arr);
267 $filePath = $this->getDir() . '/' . $fileName;
268 if (file_exists($filePath)) {
269 wp_delete_file($filePath);
270 }
271 }
272 }
273
274 /**
275 * Register filters for custom upload dir
276 */
277 public function _overrideUploadDir()
278 {
279 add_filter('wp_handle_upload_prefilter', [$this, '_renameFileName']);
280 add_filter('upload_dir', [$this, '_setCustomUploadDir']);
281 }
282
283 /**
284 * Set plugin's custom upload dir
285 * @param array $param
286 * @return array $param
287 */
288 public function _setCustomUploadDir($param)
289 {
290 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
291
292 if ($this->subDir) {
293 $fbsUploadDir .= '/' . $this->subDir;
294 }
295
296 $param['url'] = trailingslashit($param['baseurl']) . trim($fbsUploadDir, '/');
297 $param['path'] = trailingslashit($param['basedir']) . trim($fbsUploadDir, '/');
298 if (!wp_mkdir_p($param['path'])) {
299 $param['error'] = __('Could not create the upload directory.', 'fluent-boards');
300 }
301
302 return $param;
303 }
304
305 /**
306 * Rename the uploaded file name before saving
307 * @param array $file
308 * @return array $file
309 */
310 public function _renameFileName($file)
311 {
312 $prefix = wp_generate_uuid4() . '-';
313 $prefix = apply_filters('fluent_boards/uploaded_file_name_prefix', $prefix);
314 $file['name'] = $prefix . $file['name'];
315
316 return $file;
317 }
318
319 public function _deleteDir($dir)
320 {
321 $root = realpath($this->_getDir());
322 $path = $root ? $root . DIRECTORY_SEPARATOR . $dir : '';
323 $resolved = $path ? realpath($path) : false;
324 // Never remove the storage root, escape it, or follow a directory symlink.
325 if (!$root || !$resolved || is_link($path)
326 || strpos($resolved, $root . DIRECTORY_SEPARATOR) !== 0) {
327 return false;
328 }
329
330 return is_dir($resolved) ? $this->deleteContents($resolved) : false;
331 }
332
333 /**
334 * Delete a directory and its content
335 * Lead Note: this method should be called via scheduler or queue
336 * @param string $dir
337 * @return bool
338 */
339 private function deleteContents($dir)
340 {
341 $files = array_diff(scandir($dir), ['.', '..']);
342 foreach ($files as $file) {
343 if (!is_link("$dir/$file") && is_dir("$dir/$file")) {
344 // Recursively delete subdirectory
345 $this->deleteContents("$dir/$file");
346 } else {
347 // Delete file
348 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Recursive directory cleanup, direct file operations required
349 unlink("$dir/$file");
350 }
351 }
352 // Delete the directory itself. If the directory is not empty, it will not be deleted.
353 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Recursive directory removal, WP_Filesystem not suitable for this operation
354 return rmdir($dir);
355 }
356
357 public static function __callStatic($method, $params)
358 {
359 $instance = new static;
360
361 return call_user_func_array([$instance, $method], $params);
362 }
363
364 public function __call($method, $params)
365 {
366 $hiddenMethod = "_" . $method;
367
368 $method = method_exists($this, $hiddenMethod) ? $hiddenMethod : $method;
369
370 return call_user_func_array([$this, $method], $params);
371 }
372 }
373