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 / JsonImportService.php

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

562 lines 19.5 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;
4
5 use InvalidArgumentException;
6 use FluentBoards\Framework\Http\Request\Request;
7 use FluentBoards\Framework\Support\Arr;
8 use FluentBoards\App\Models\Board;
9 use FluentBoards\App\Models\Meta;
10 use FluentBoards\App\Services\FolderService;
11
12 /** Stage native board exports and restore them in bounded task batches. */
13 class JsonImportService
14 {
15 private $externalImporter;
16
17 /** Accept an optional Pro handler for third-party JSON exports. */
18 public function __construct($externalImporter = null)
19 {
20 $this->externalImporter = $externalImporter;
21 }
22
23 /** Reject invalid import input for the controller to report to the client. */
24 private function fail($error, $code = 422)
25 {
26 throw new InvalidArgumentException(is_array($error) ? $error['message'] : $error, $code);
27 }
28
29 /** Validate and import an uploaded JSON board export. */
30 public function importFile(Request $request)
31 {
32 $file = Arr::get($request->files(), 'file');
33 $file = $file ? $file->toArray() : null;
34 $importFrom = $request->getSafe('importFrom');
35
36 if (!$file || empty($file['tmp_name'])) {
37 return $this->fail([
38 'message' => __('File is empty.', 'fluent-boards'),
39 'type' => 'warning',
40 ]);
41 }
42
43 if (!empty($file['error'])) {
44 return $this->fail([
45 'message' => __('The file could not be uploaded. Please try again.', 'fluent-boards'),
46 'type' => 'warning',
47 ]);
48 }
49
50 // Guard against a path being passed off as an upload
51 if (!is_uploaded_file($file['tmp_name'])) {
52 return $this->fail([
53 'message' => __('Invalid file upload.', 'fluent-boards'),
54 'type' => 'warning',
55 ]);
56 }
57
58 // Only JSON board exports are accepted here. The file is never written into the
59 // uploads directory - it is read from PHP's temp file and discarded with the request.
60 $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
61 if ($extension !== 'json') {
62 return $this->fail([
63 'message' => __('Only .json import files are allowed.', 'fluent-boards'),
64 'type' => 'warning',
65 ]);
66 }
67
68 $fileSize = (int)$file['size'];
69 $maxFileSize = (int)apply_filters('fluent_boards/import_max_file_size', 64 * 1024 * 1024);
70 if ($fileSize <= 0 || $fileSize > $maxFileSize) {
71 return $this->fail([
72 'message' => __('The import file is empty or exceeds the allowed size.', 'fluent-boards'),
73 'type' => 'warning',
74 ]);
75 }
76
77 $jsonString = file_get_contents($file['tmp_name']);
78 $importData = json_decode($jsonString, true);
79 unset($jsonString);
80
81 if (!is_array($importData)) {
82 return $this->fail([
83 'message' => __("Invalid JSON.", 'fluent-boards'),
84 'type' => 'warning',
85 ]);
86 }
87
88 $board = $importFrom === Constant::FLUENT_BOARDS_IMPORT
89 ? $this->importFromFluentBoards($importData)
90 : $this->importExternalSource($importFrom, $importData);
91
92 if(!$board){
93 return $this->fail([
94 'message' => __("Invalid JSON.", 'fluent-boards'),
95 'type' => 'warning',
96 ]);
97 }
98
99 return [
100 'message' => __('Processed and Imported successfully', 'fluent-boards'),
101 'board_id' => $board ? $board->id: null,
102 'success' => true,
103 ];
104
105 }
106
107 /** Allow the Pro controller to handle third-party JSON exports. */
108 protected function importExternalSource($source, $importData)
109 {
110 return $this->externalImporter ? call_user_func($this->externalImporter, $source, $importData) : null;
111 }
112
113 /** Validate the Fluent Boards export marker before importing. */
114 private function importFromFluentBoards($importData)
115 {
116 try {
117 if (!is_string($importData['key'] ?? null) || !is_array($importData['board'] ?? null)) {
118 return null;
119 } else {
120 if (!str_contains($importData['key'], Constant::FLUENT_BOARDS_IMPORT)) {
121 return null;
122 } else {
123 return FluentBoardsImporter::process($importData);
124 }
125 }
126 } catch (\Exception $e) {
127 return $this->fail($e->getMessage(), 400);
128 }
129 }
130
131 /** Stage a JSON upload chunk outside the public uploads directory. */
132 public function uploadJsonChunk(Request $request)
133 {
134 $chunk = Arr::get($request->files(), 'chunk');
135
136 if (!$chunk) {
137 return $this->fail([
138 'message' => __('Chunk data is missing.', 'fluent-boards')
139 ]);
140 }
141
142 $chunk = $chunk->toArray();
143
144 if (!empty($chunk['error']) || empty($chunk['tmp_name']) || !is_uploaded_file($chunk['tmp_name'])) {
145 return $this->fail([
146 'message' => __('The chunk could not be uploaded. Please try again.', 'fluent-boards')
147 ]);
148 }
149
150 $chunkIndex = intval($request->getSafe('chunkIndex')); // 0, 1, 2, ...
151 $totalChunks = intval($request->getSafe('totalChunks')); // e.g. 35
152 $uploadId = sanitize_file_name($request->getSafe('uploadId')); // unique per upload session
153 $fileName = $this->sanitizeImportFileName($uploadId . '.json');
154
155 if (!$fileName) {
156 return $this->fail([
157 'message' => __('Invalid upload id.', 'fluent-boards')
158 ]);
159 }
160
161 if ($chunkIndex < 0 || $totalChunks < 1) {
162 return $this->fail([
163 'message' => __('Invalid chunk information.', 'fluent-boards')
164 ]);
165 }
166
167 $dirPath = $this->getImportDirPath();
168 if (!$dirPath) {
169 return $this->fail([
170 'message' => __('Upload directory information not available.', 'fluent-boards')
171 ]);
172 }
173
174 // All chunks for this upload go into the same file
175 $filePath = $dirPath . $fileName;
176
177 // If this is the first chunk, remove any leftover file from a previous attempt
178 if ($chunkIndex === 0 && file_exists($filePath)) {
179 @unlink($filePath);
180 }
181
182 $maxFileSize = (int)apply_filters('fluent_boards/import_max_file_size', 64 * 1024 * 1024);
183 $writtenSoFar = file_exists($filePath) ? (int)filesize($filePath) : 0;
184 if (($writtenSoFar + (int)$chunk['size']) > $maxFileSize) {
185 @unlink($filePath);
186 return $this->fail([
187 'message' => __('The import file exceeds the allowed size.', 'fluent-boards')
188 ]);
189 }
190
191 // Read the chunk bytes and APPEND to the file
192 $chunkContent = file_get_contents($chunk['tmp_name']);
193 file_put_contents($filePath, $chunkContent, FILE_APPEND | LOCK_EX);
194
195 return [
196 'chunkIndex' => $chunkIndex,
197 'isComplete' => ($chunkIndex + 1) >= $totalChunks,
198 // Only the file name is handed back — never an absolute path. The
199 // processing phase resolves it inside the import directory itself.
200 'filePath' => $fileName,
201 ];
202 }
203
204 /**
205 * The one directory chunked imports are allowed to touch.
206 */
207 private function getImportDirPath()
208 {
209 $uploadDir = wp_upload_dir();
210 $blockedDirs = array_filter([
211 defined('WP_CONTENT_DIR') ? realpath(WP_CONTENT_DIR) : '',
212 !empty($uploadDir['basedir']) ? realpath($uploadDir['basedir']) : '',
213 ]);
214
215 $baseDirs = [
216 defined('WP_TEMP_DIR') ? WP_TEMP_DIR : '',
217 function_exists('sys_get_temp_dir') ? sys_get_temp_dir() : '',
218 ini_get('upload_tmp_dir'),
219 '/tmp',
220 ];
221
222 $baseDir = '';
223 foreach ($baseDirs as $candidateDir) {
224 if (!$candidateDir) {
225 continue;
226 }
227
228 if (!is_dir($candidateDir) && !wp_mkdir_p($candidateDir)) {
229 continue;
230 }
231
232 $realCandidateDir = realpath($candidateDir);
233 if (!$realCandidateDir || !wp_is_writable($realCandidateDir)) {
234 continue;
235 }
236
237 foreach ($blockedDirs as $blockedDir) {
238 $blockedDir = rtrim($blockedDir, '/\\');
239
240 $isInsideBlockedDir = strpos($realCandidateDir, $blockedDir . DIRECTORY_SEPARATOR) === 0;
241
242 if ($realCandidateDir === $blockedDir || $isInsideBlockedDir) {
243 continue 2;
244 }
245 }
246
247 $baseDir = $realCandidateDir;
248 break;
249 }
250
251 if (!$baseDir) {
252 return '';
253 }
254
255 $dirPath = trailingslashit($baseDir) . 'fluent-boards-imports-' . md5(ABSPATH) . '/';
256
257 if (!is_dir($dirPath) && !wp_mkdir_p($dirPath)) {
258 return '';
259 }
260
261 // Staged board imports live outside uploads; web-server rules are defense in depth.
262 if (!file_exists($dirPath . '.htaccess')) {
263 @file_put_contents($dirPath . '.htaccess', "Require all denied\nDeny from all\n");
264 }
265
266 if (!file_exists($dirPath . 'index.html')) {
267 @file_put_contents($dirPath . 'index.html', '');
268 }
269
270 return $dirPath;
271 }
272
273 /**
274 * Reduce whatever the client sent to a bare, safe *.json file name.
275 * Returns an empty string when the value cannot be trusted.
276 */
277 private function sanitizeImportFileName($value)
278 {
279 $fileName = sanitize_file_name(basename(str_replace('\\', '/', (string)$value)));
280
281 if (!preg_match('/^[A-Za-z0-9._-]+\.json$/', $fileName)) {
282 return '';
283 }
284
285 if (strpos($fileName, '..') !== false) {
286 return '';
287 }
288
289 return $fileName;
290 }
291
292 /**
293 * Resolve a client supplied import file reference to a real path that is
294 * guaranteed to live inside the plugin's own import directory.
295 */
296 private function resolveImportFilePath($value)
297 {
298 $fileName = $this->sanitizeImportFileName($value);
299 $dirPath = $this->getImportDirPath();
300
301 if (!$fileName || !$dirPath) {
302 return '';
303 }
304
305 $realDir = realpath($dirPath);
306 $realFile = realpath($dirPath . $fileName);
307
308 if (!$realDir || !$realFile || !is_file($realFile)) {
309 return '';
310 }
311
312 $realDir = rtrim($realDir, '/\\') . DIRECTORY_SEPARATOR;
313
314 if (strpos($realFile, $realDir) !== 0) {
315 return '';
316 }
317
318 return $realFile;
319 }
320
321 /** Initialize or resume a batched JSON import. */
322 public function processJsonImport(Request $request)
323 {
324 $page = max(1, intval($request->get('importing_page', 1)));
325 $boardId = absint($request->get('board_id', 0));
326 $processPerRequest = 200;
327
328 if (ini_get('max_execution_time') < 300) {
329 @ini_set('max_execution_time', '300');
330 }
331
332 if ($page === 1) {
333 return $this->initializeJsonImport($request, $processPerRequest);
334 }
335
336 return $this->processJsonImportPage($boardId, $page, $processPerRequest);
337 }
338
339 /** Validate the target folder, create the imported board, and stage task batches. */
340 private function initializeJsonImport(Request $request, $processPerRequest)
341 {
342 // Never trust a filesystem path from the client: the reference is
343 // reduced to a file name and resolved inside the import directory.
344 $filePath = $this->resolveImportFilePath($request->get('filePath'));
345 $importFrom = $request->getSafe('importFrom');
346
347 if (!$filePath) {
348 return $this->fail([
349 'message' => __('Import file not found.', 'fluent-boards')
350 ]);
351 }
352
353 // json_decode needs ~8x the file size in memory.
354 $fileSize = filesize($filePath);
355 $requiredMemory = max(536870912, $fileSize * 8); // at least 512MB
356 $currentMemory = wp_convert_hr_to_bytes(ini_get('memory_limit'));
357 if ($currentMemory < $requiredMemory) {
358 @ini_set('memory_limit', ceil($requiredMemory / 1048576) . 'M');
359 }
360
361 // Read and parse the full JSON (only happens once on page 1)
362 $jsonString = file_get_contents($filePath);
363 $importData = json_decode($jsonString, true);
364 unset($jsonString); // Free memory immediately
365
366 if (!is_array($importData)) {
367 @unlink($filePath);
368 return $this->fail([
369 'message' => __('Invalid JSON file. Please check the file and try again.', 'fluent-boards')
370 ]);
371 }
372
373 if ($importFrom !== Constant::FLUENT_BOARDS_IMPORT) {
374 $board = $this->importExternalSource($importFrom, $importData);
375 @unlink($filePath);
376 if (!$board) {
377 return $this->fail(['message' => __('Unsupported or invalid import file.', 'fluent-boards')]);
378 }
379 return [
380 'message' => __('Imported successfully', 'fluent-boards'),
381 'board_id' => $board->id,
382 'has_more' => false,
383 ];
384 }
385
386 // FluentBoards JSON: validate
387 if (!is_string($importData['key'] ?? null) || !str_contains($importData['key'], Constant::FLUENT_BOARDS_IMPORT) || !is_array($importData['board'] ?? null)) {
388 @unlink($filePath);
389 return $this->fail([
390 'message' => __('Invalid FluentBoards JSON file.', 'fluent-boards')
391 ]);
392 }
393
394 $folderId = absint($request->get('folder_id', $request->get('folderId', 0)));
395 $folderService = new FolderService();
396 if ($folderId) {
397 // Validate before creating a board so inaccessible folders cannot leave orphaned imports.
398 $folderService->assertCanModifyFolder($folderId);
399 }
400
401 // Create board structure (stages, labels, members, custom fields) — no tasks yet
402 $result = FluentBoardsImporter::initializeBoard($importData);
403 $board = $result['board'];
404 $boardId = $board->id;
405 if ($folderId) {
406 $folderService->addBoardToFolder($folderId, [$boardId]);
407 }
408 $tasks = $importData['board']['tasks'] ?? [];
409 $totalTasks = count($tasks);
410 unset($importData); // Free the huge array from memory
411
412 $importDir = $this->getImportDirPath() . 'import_' . $boardId . '/';
413 wp_mkdir_p($importDir);
414
415 $totalPages = max(1, ceil($totalTasks / $processPerRequest));
416 for ($i = 0; $i < $totalPages; $i++) {
417 $pageTasks = array_slice($tasks, $i * $processPerRequest, $processPerRequest);
418 file_put_contents(
419 $importDir . 'tasks_page_' . ($i + 1) . '.json',
420 wp_json_encode($pageTasks)
421 );
422 }
423 unset($tasks); // Free memory
424
425 // Save import session to database so page 2+ can access the mappers
426 Meta::create([
427 'object_id' => $boardId,
428 'object_type' => Constant::JSON_IMPORT,
429 'key' => 'json_import_' . $boardId,
430 'value' => [
431 'mappers' => $result['mappers'],
432 'siteUrl' => $result['siteUrl'],
433 'totalTasks' => $totalTasks,
434 'totalPages' => $totalPages,
435 'importDir' => $importDir,
436 ],
437 ]);
438
439 // Delete the original large file — we have page files now
440 @unlink($filePath);
441
442 // Process the first batch
443 $pageFile = $importDir . 'tasks_page_1.json';
444 if (file_exists($pageFile)) {
445 $taskBatch = json_decode(file_get_contents($pageFile), true);
446 if ($taskBatch) {
447 FluentBoardsImporter::processTaskBatch(
448 $board, $result['mappers'], $result['siteUrl'], $taskBatch
449 );
450 }
451 @unlink($pageFile); // Delete after processing
452 }
453
454 $completed = min($processPerRequest, $totalTasks);
455 $hasMore = $completed < $totalTasks;
456
457 if (!$hasMore) {
458 FluentBoardsImporter::finalizeImport($boardId);
459 $this->cleanupImportSession($boardId, $importDir);
460 }
461
462 return [
463 'total' => $totalTasks,
464 'completed' => $completed,
465 'total_page' => $totalPages,
466 'has_more' => $hasMore,
467 'last_page' => 1,
468 'board_id' => $boardId,
469 'message' => $hasMore
470 ? __('Processing...', 'fluent-boards')
471 : __('Board imported successfully', 'fluent-boards'),
472 ];
473 }
474
475 /** Import the next staged task batch for the requested board. */
476 private function processJsonImportPage($boardId, $page, $processPerRequest)
477 {
478 if (!$boardId) {
479 return $this->fail([
480 'message' => __('Board ID is required.', 'fluent-boards')
481 ]);
482 }
483
484 $board = Board::findOrFail($boardId);
485 $meta = Meta::where('object_id', $boardId)
486 ->where('object_type', Constant::JSON_IMPORT)
487 ->where('key', 'json_import_' . $boardId)
488 ->first();
489
490 if (!$meta) {
491 return $this->fail([
492 'message' => __('Import session not found. Please restart the import.', 'fluent-boards')
493 ]);
494 }
495
496 $session = $meta->value;
497 $importDir = $session['importDir'];
498 $totalTasks = $session['totalTasks'];
499 $totalPages = $session['totalPages'];
500
501 // Skip pages whose file is already gone (processed during a timed-out request)
502 while ($page <= $totalPages) {
503 $pageFile = $importDir . 'tasks_page_' . $page . '.json';
504 if (file_exists($pageFile)) {
505 break; // Found an unprocessed page
506 }
507 $page++; // This page was already done, skip it
508 }
509
510 // Process the page if it exists
511 if ($page <= $totalPages) {
512 $pageFile = $importDir . 'tasks_page_' . $page . '.json';
513 $taskBatch = json_decode(file_get_contents($pageFile), true);
514 if ($taskBatch) {
515 FluentBoardsImporter::processTaskBatch(
516 $board, $session['mappers'], $session['siteUrl'], $taskBatch
517 );
518 }
519 @unlink($pageFile);
520 }
521
522 $completed = min($page * $processPerRequest, $totalTasks);
523 $hasMore = $page < $totalPages;
524
525 if (!$hasMore) {
526 FluentBoardsImporter::finalizeImport($boardId);
527 $this->cleanupImportSession($boardId, $importDir);
528 }
529
530 return [
531 'total' => $totalTasks,
532 'completed' => $completed,
533 'total_page' => $totalPages,
534 'has_more' => $hasMore,
535 'last_page' => $page,
536 'board_id' => $boardId,
537 'message' => $hasMore
538 ? __('Processing...', 'fluent-boards')
539 : __('Board imported successfully', 'fluent-boards'),
540 ];
541 }
542
543 /**
544 * Clean up after import is complete: delete the Meta session and any leftover page files.
545 */
546 private function cleanupImportSession($boardId, $importDir)
547 {
548 Meta::where('object_id', $boardId)
549 ->where('object_type', Constant::JSON_IMPORT)
550 ->where('key', 'json_import_' . $boardId)
551 ->delete();
552
553 $files = glob($importDir . 'tasks_page_*.json');
554 if ($files) {
555 foreach ($files as $file) {
556 @unlink($file);
557 }
558 }
559 @rmdir($importDir);
560 }
561 }
562