Passed
Pull Request — master (#5018)
by
unknown
08:18
created

TaskWorker::handleDatabaseBackup()   F

Complexity

Conditions 21
Paths 2610

Size

Total Lines 111
Code Lines 62

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 21
eloc 62
c 2
b 0
f 0
nc 2610
nop 1
dl 0
loc 111
rs 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Teampass - a collaborative passwords manager.
4
 * ---
5
 * This file is part of the TeamPass project.
6
 * 
7
 * TeamPass is free software: you can redistribute it and/or modify it
8
 * under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, version 3 of the License.
10
 * 
11
 * TeamPass is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
 * GNU General Public License for more details.
15
 * 
16
 * You should have received a copy of the GNU General Public License
17
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
 * 
19
 * Certain components of this file may be under different licenses. For
20
 * details, see the `licenses` directory or individual file headers.
21
 * ---
22
 * @file      background_tasks___worker.php
23
 * @author    Nils Laumaillé ([email protected])
24
 * @copyright 2009-2026 Teampass.net
25
 * @license   GPL-3.0
26
 * @see       https://www.teampass.net
27
 */
28
29
use TeampassClasses\ConfigManager\ConfigManager;
30
require_once __DIR__.'/../sources/main.functions.php';
31
require_once __DIR__.'/background_tasks___functions.php';
32
require_once __DIR__.'/traits/ItemHandlerTrait.php';
33
require_once __DIR__.'/traits/UserHandlerTrait.php';
34
require_once __DIR__.'/traits/EmailTrait.php';
35
require_once __DIR__.'/traits/MigrateUserHandlerTrait.php';
36
require_once __DIR__ . '/taskLogger.php';
37
38
class TaskWorker {
39
    use ItemHandlerTrait;
40
    use UserHandlerTrait;
41
    use EmailTrait;
42
    use MigrateUserHandlerTrait;
43
44
    private $taskId;
45
    private $processType;
46
    private $taskData;
47
    private $settings;
48
    private $logger;
49
50
    public function __construct(int $taskId, string $processType, array $taskData) {
51
        $this->taskId = $taskId;
52
        $this->processType = $processType;
53
        $this->taskData = $taskData;
54
        
55
        $configManager = new ConfigManager();
56
        $this->settings = $configManager->getAllSettings();
57
        $this->logger = new TaskLogger($this->settings, LOG_TASKS_FILE);
58
    }
59
60
    /**
61
     * Execute the task based on its type.
62
     * This method will handle different types of tasks such as item copy, new item creation,
63
     * user cache tree building, email sending, and user key generation.
64
     * 
65
     * @return void
66
     */
67
    public function execute() {
68
        try {
69
            if (LOG_TASKS=== true) $this->logger->log('Processing task: ' . print_r($this->taskData, true), 'DEBUG');
70
            // Dispatch selon le type de processus
71
            switch ($this->processType) {
72
                case 'item_copy':
73
                    $this->processSubTasks($this->taskData);
74
                    break;
75
                case 'new_item':
76
                    $this->processSubTasks($this->taskData);
77
                    break;
78
                case 'item_update_create_keys':
79
                    $this->processSubTasks($this->taskData);
80
                    break;
81
                case 'user_build_cache_tree':
82
                    $this->handleUserBuildCacheTree($this->taskData);
83
                    break;
84
                case 'send_email':
85
                    $this->sendEmail($this->taskData);
86
                    break;
87
                case 'create_user_keys':
88
                    $this->generateUserKeys($this->taskData);
89
                    break;
90
                case 'migrate_user_personal_items':
91
                    $this->migratePersonalItems($this->taskData);
92
                    break;
93
                case 'database_backup':
94
                    $this->handleDatabaseBackup($this->taskData);
95
                    break;
96
                default:
97
                    throw new Exception("Type of subtask unknown: {$this->processType}");
98
            }
99
100
            // Mark the task as completed
101
            try {
102
                $this->completeTask();
103
            } catch (Throwable $e) {
104
                $this->handleTaskFailure($e);
105
            }
106
107
        } catch (Throwable $e) {
108
            $this->handleTaskFailure($e);
109
        }
110
111
    }
112
    /**
113
     * Perform a scheduled database backup (encrypted) into files/backups.
114
     */
115
    private function handleDatabaseBackup(array $taskData): void
116
    {
117
        require_once __DIR__ . '/../sources/backup.functions.php';
118
119
        // Default target dir: <path_to_files_folder>/backups
120
        $baseFilesDir = (string)($this->settings['path_to_files_folder'] ?? (__DIR__ . '/../files'));
121
        $targetDir = rtrim($baseFilesDir, '/') . '/backups';
122
123
        // Allow override via task arguments (optional)
124
        if (!empty($taskData['output_dir']) && is_string($taskData['output_dir'])) {
125
            $targetDir = rtrim($taskData['output_dir'], '/');
126
        }
127
128
        if (!is_dir($targetDir)) {
129
            if (!@mkdir($targetDir, 0770, true) && !is_dir($targetDir)) {
130
                throw new Exception('Cannot create backup target dir: ' . $targetDir);
131
            }
132
        }
133
        if (!is_writable($targetDir)) {
134
            throw new Exception('Backup target dir is not writable: ' . $targetDir);
135
        }
136
137
        // Use stored encryption key (same as UI)
138
        $encryptionKey = (string)($this->settings['bck_script_passkey'] ?? '');
139
        if ($encryptionKey === '') {
140
            throw new Exception('Missing encryption key (bck_script_passkey).');
141
        }
142
143
// Auto-disconnect connected users before running a scheduled backup.
144
// Exclude the user who enqueued the task (manual run), if provided.
145
try {
146
    if (function_exists('loadClasses') && !class_exists('DB')) {
147
        loadClasses('DB');
148
    }
149
    $excludeUserId = (int) ($taskData['initiator_user_id'] ?? 0);
150
    $now = time();
151
152
    if ($excludeUserId > 0) {
153
        $connectedUsers = DB::query(
154
            'SELECT id FROM ' . prefixTable('users') . ' WHERE session_end >= %i AND id != %i',
155
            $now,
156
            $excludeUserId
157
        );
158
    } else {
159
        $connectedUsers = DB::query(
160
            'SELECT id FROM ' . prefixTable('users') . ' WHERE session_end >= %i',
161
            $now
162
        );
163
    }
164
165
    foreach ($connectedUsers as $u) {
166
        DB::update(
167
            prefixTable('users'),
168
            [
169
                'key_tempo' => '',
170
                'timestamp' => '',
171
                'session_end' => '',
172
            ],
173
            'id = %i',
174
            (int) $u['id']
175
        );
176
    }
177
} catch (Throwable $ignored) {
178
    // Best effort only - do not block backups if disconnection cannot be done
179
}
180
181
        $res = tpCreateDatabaseBackup($this->settings, $encryptionKey, [
182
            'output_dir' => $targetDir,
183
            'filename_prefix' => 'scheduled-',
184
        ]);
185
186
        if (($res['success'] ?? false) !== true) {
187
            throw new Exception($res['message'] ?? 'Backup failed');
188
        }
189
190
        // Store a tiny summary for the task completion "arguments" field (no secrets)
191
        $this->taskData['backup_file'] = $res['filename'] ?? '';
192
        $this->taskData['backup_size_bytes'] = (int)($res['size_bytes'] ?? 0);
193
        $this->taskData['backup_encrypted'] = (bool)($res['encrypted'] ?? false);
194
195
        // Retention purge (scheduled backups only)
196
        $backupSource = (string)($taskData['source'] ?? '');
197
        $backupDir = (string)($taskData['output_dir'] ?? '');   // from task arguments (reliable)
198
        if ($backupDir === '') {
199
            $backupDir = (string)($targetDir ?? '');            // fallback if you have it
200
        }
201
202
        // keep for debug/trace
203
        $this->taskData['output_dir'] = $backupDir;
204
205
        if ($backupSource === 'scheduler' && $backupDir !== '') {
206
            $days = (int)$this->getMiscSetting('bck_scheduled_retention_days', '30');
207
            $deleted = $this->purgeOldScheduledBackups($backupDir, $days);
208
209
            $this->upsertMiscSetting('bck_scheduled_last_purge_at', (string)time());
210
            $this->upsertMiscSetting('bck_scheduled_last_purge_deleted', (string)$deleted);
211
212
            if (LOG_TASKS === true) {
213
                $this->logger->log("database_backup: purge retention={$days}d dir={$backupDir} deleted={$deleted}", 'INFO');
214
            }
215
        }
216
217
        // If launched by scheduler, update scheduler status in teampass_misc
218
        if (!empty($taskData['source']) && $taskData['source'] === 'scheduler') {
219
            $this->updateSchedulerState('completed', 'Backup created: ' . ($this->taskData['backup_file'] ?? ''));
220
        }
221
222
        if (LOG_TASKS === true) {
223
            $this->logger->log(
224
                'database_backup: created ' . ($this->taskData['backup_file'] ?? '') . ' (' . $this->taskData['backup_size_bytes'] . ' bytes)',
225
                'INFO'
226
            );
227
        }
228
    }
229
230
    /**
231
     * Mark the task as completed in the database.
232
     * This method updates the task status to 'completed' and sets the finished_at timestamp.
233
     * 
234
     * @return void
235
     */
236
    private function updateSchedulerState(string $status, string $message): void
237
    {
238
        $this->upsertMiscSetting('bck_scheduled_last_status', $status);
239
        $this->upsertMiscSetting('bck_scheduled_last_message', mb_substr($message, 0, 500));
240
        $this->upsertMiscSetting('bck_scheduled_last_completed_at', (string)time());
241
    }
242
243
    private function upsertMiscSetting(string $key, string $value): void
244
    {
245
        $table = prefixTable('misc');
246
247
        $exists = (int)DB::queryFirstField(
248
            'SELECT COUNT(*) FROM ' . $table . ' WHERE type = %s AND intitule = %s',
249
            'settings',
250
            $key
251
        );
252
253
        if ($exists > 0) {
254
            DB::update($table, ['valeur' => $value], 'type = %s AND intitule = %s', 'settings', $key);
255
        } else {
256
            DB::insert($table, ['type' => 'settings', 'intitule' => $key, 'valeur' => $value]);
257
        }
258
    }
259
260
    private function getMiscSetting(string $key, string $default = ''): string
261
    {
262
        $table = prefixTable('misc');
263
264
        $val = DB::queryFirstField(
265
            'SELECT valeur FROM ' . $table . ' WHERE type = %s AND intitule = %s LIMIT 1',
266
            'settings',
267
            $key
268
        );
269
270
        if ($val === null || $val === false || $val === '') {
271
            return $default;
272
        }
273
274
        return (string) $val;
275
    }
276
277
    private function purgeOldScheduledBackups(string $dir, int $retentionDays): int
278
    {
279
        if ($retentionDays <= 0) {
280
            return 0; // 0 => désactivé
281
        }
282
283
        if (!is_dir($dir)) {
284
            return 0;
285
        }
286
287
        $cutoff = time() - ($retentionDays * 86400);
288
        $deleted = 0;
289
290
        foreach (glob(rtrim($dir, '/') . '/scheduled-*.sql') as $file) {
291
            if (!is_file($file)) {
292
                continue;
293
            }
294
295
            $mtime = @filemtime($file);
296
            if ($mtime !== false && $mtime < $cutoff) {
297
                if (@unlink($file)) {
298
                    $deleted++;
299
                }
300
            }
301
        }
302
303
        return $deleted;
304
    }
305
306
    private function completeTask() {
307
        // Prepare data for updating the task status
308
        $updateData = [
309
            'is_in_progress' => -1,
310
            'finished_at' => time(),
311
            'status' => 'completed',
312
            'error_message' => null,   // <-- on efface toute erreur précédente
313
        ];
314
315
        // Prepare anonimzation of arguments
316
        if ($this->processType === 'send_mail') {
317
            $arguments = json_encode(
318
                [
319
                    'email' => $this->taskData['receivers'],
320
                    'login' => $this->taskData['receiver_name'],
321
                ]
322
            );
323
        } elseif ($this->processType === 'create_user_keys' || $this->processType === 'migrate_user_personal_items') {
324
            $arguments = json_encode(
325
                [
326
                    'user_id' => $this->taskData['new_user_id'],
327
                ]
328
            );
329
        } elseif ($this->processType === 'item_update_create_keys') {
330
            $arguments = json_encode(
331
                [
332
                    'item_id' => $this->taskData['item_id'],
333
                    'author' => $this->taskData['author'],
334
                ]
335
            );
336
        } elseif ($this->processType === 'database_backup') {
337
            $arguments = json_encode(
338
                [
339
                    'file' => $this->taskData['backup_file'] ?? '',
340
                    'size_bytes' => $this->taskData['backup_size_bytes'] ?? 0,
341
                    'encrypted' => $this->taskData['backup_encrypted'] ?? false,
342
                ]
343
            );
344
        } else {
345
            $arguments = '';
346
        }
347
348
        if (LOG_TASKS=== true) $this->logger->log('Process: '.$this->processType.' -- '.print_r($arguments, true), 'DEBUG');
349
350
        // Add 'arguments' only if not empty
351
        if (!empty($arguments)) {
352
            $updateData['arguments'] = $arguments;
353
        }
354
355
        // Store completed status in the database
356
        DB::update(
357
            prefixTable('background_tasks'),
358
            $updateData,
359
            'increment_id = %i',
360
            $this->taskId
361
        );
362
363
        if (LOG_TASKS=== true) $this->logger->log('Finishing task: ' . $this->taskId, 'DEBUG');
364
    }
365
366
    /**
367
     * Handle task failure by updating the task status in the database.
368
     * This method sets the task status to 'failed', updates the finished_at timestamp,
369
     * and logs the error message.
370
     * 
371
     * @param Exception $e The exception that occurred during task processing.
372
     * @return void
373
     */
374
    private function handleTaskFailure(Throwable $e) {
375
        DB::update(
376
            prefixTable('background_tasks'),
377
            [
378
                'is_in_progress' => -1,
379
                'finished_at' => time(),
380
                'status' => 'failed',
381
                'error_message' => $e->getMessage()
382
            ],
383
            'increment_id = %i',
384
            $this->taskId
385
        );
386
        $this->logger->log('Task failure: ' . $e->getMessage(), 'ERROR');
387
    // Purge retention even on failure (safe: only scheduled-*.sql)
388
        $backupDir = (string)($this->taskData['output_dir'] ?? '');
389
        if ($backupDir !== '' && is_dir($backupDir)) {
390
        $days = (int)$this->getMiscSetting('bck_scheduled_retention_days', '30');
391
        $deleted = $this->purgeOldScheduledBackups($backupDir, $days);
392
393
        $this->upsertMiscSetting('bck_scheduled_last_purge_at', (string)time());
394
        $this->upsertMiscSetting('bck_scheduled_last_purge_deleted', (string)$deleted);
395
        }
396
    }
397
398
    /**
399
     * Handle subtasks for the current task.
400
     * This method retrieves all subtasks related to the current task and processes them.
401
     * If all subtasks are completed, it marks the main task as completed.
402
     * 
403
     * @param array $arguments Arguments for the subtasks.
404
     * @return void
405
     */
406
    private function processSubTasks($arguments) {
407
        if (LOG_TASKS=== true) $this->logger->log('processSubTasks: '.print_r($arguments, true), 'DEBUG');
408
        // Get all subtasks related to this task
409
        $subtasks = DB::query(
410
            'SELECT * FROM ' . prefixTable('background_subtasks') . ' WHERE task_id = %i AND is_in_progress = 0 ORDER BY `task` ASC',
411
            $this->taskId
412
        );
413
    
414
        // Check if there are any subtasks to process
415
        if (empty($subtasks)) {
416
            if (LOG_TASKS=== true) $this->logger->log('No subtask was found for task: ' . $this->taskId, 'DEBUG');
417
            return;
418
        }
419
    
420
        // Process each subtask
421
        foreach ($subtasks as $subtask) {
422
            try {
423
                // Get the subtask data
424
                $subtaskData = json_decode($subtask['task'], true);
425
426
                if (LOG_TASKS=== true) $this->logger->log('Processing subtask: ' . $subtaskData['step'], 'DEBUG');
427
428
                // Mark subtask as in progress
429
                DB::update(
430
                    prefixTable('background_tasks'),
431
                    ['updated_at' => time()],
432
                    'increment_id = %i',
433
                    $this->taskId
434
                );
435
436
                // Process the subtask based on its type
437
                switch ($subtaskData['step'] ?? '') {
438
                    case 'create_users_pwd_key':
439
                        $this->generateUserPasswordKeys($arguments);
440
                        break;
441
                    case 'create_users_fields_key':
442
                        $this->generateUserFieldKeys($subtaskData);
443
                        break;
444
                    case 'create_users_files_key':
445
                        $this->generateUserFileKeys($subtaskData);
446
                        break;
447
                    default:
448
                        throw new Exception("Type de sous-tâche inconnu (".$subtaskData['step'].")");
449
                }                
450
        
451
                // Mark subtask as completed
452
                DB::update(
453
                    prefixTable('background_subtasks'),
454
                    [
455
                        'is_in_progress' => -1,
456
                        'finished_at' => time(),
457
                        'status' => 'completed',
458
                    ],
459
                    'increment_id = %i',
460
                    $subtask['increment_id']
461
                );
462
        
463
            } catch (Exception $e) {
464
                // Mark subtask as failed
465
                DB::update(
466
                    prefixTable('background_subtasks'),
467
                    [
468
                        'is_in_progress' => -1,
469
                        'finished_at' => time(),
470
                        'updated_at' => time(),
471
                        'status' => 'failed',
472
                        'error_message' => $e->getMessage(),
473
                    ],
474
                    'increment_id = %i',
475
                    $subtask['increment_id']
476
                );
477
        
478
                $this->logger->log('processSubTasks : ' . $e->getMessage(), 'ERROR');
479
            }
480
        }
481
    
482
        // Are all subtasks completed?
483
        $remainingSubtasks = DB::queryFirstField(
484
            'SELECT COUNT(*) FROM ' . prefixTable('background_subtasks') . ' WHERE task_id = %i AND is_in_progress = 0',
485
            $this->taskId
486
        );
487
    
488
        if ($remainingSubtasks == 0) {
489
            $this->completeTask();
490
        }
491
    }
492
}
493
494
// Prepare the environment
495
// Get the task ID and process type from command line arguments
496
if ($argc < 3) {
497
    error_log("Usage: php background_tasks___worker.php <task_id> <process_type> [<task_data>]");
498
    exit(1);
499
}
500
$taskId = (int)$argv[1];
501
$processType = $argv[2];
502
$taskData = $argv[3] ?? null;
503
if ($taskData) {
504
    $taskData = json_decode($taskData, true);
505
    if (!is_array($taskData)) {
506
        $taskData = [];
507
    }
508
} else {
509
    $taskData = [];
510
}
511
512
// Initialize the worker
513
$worker = new TaskWorker($taskId, $processType, $taskData);
514
$worker->execute();
515