Passed
Pull Request — master (#5017)
by
unknown
06:15
created

TaskWorker::handleTaskFailure()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 21
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 15
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 21
rs 9.7666
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-2025 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
        $res = tpCreateDatabaseBackup($this->settings, $encryptionKey, [
144
            'output_dir' => $targetDir,
145
            'filename_prefix' => 'scheduled-',
146
        ]);
147
148
        if (($res['success'] ?? false) !== true) {
149
            throw new Exception($res['message'] ?? 'Backup failed');
150
        }
151
152
        // Store a tiny summary for the task completion "arguments" field (no secrets)
153
        $this->taskData['backup_file'] = $res['filename'] ?? '';
154
        $this->taskData['backup_size_bytes'] = (int)($res['size_bytes'] ?? 0);
155
        $this->taskData['backup_encrypted'] = (bool)($res['encrypted'] ?? false);
156
157
        // Retention purge (scheduled backups only)
158
        $backupSource = (string)($taskData['source'] ?? '');
159
        $backupDir = (string)($taskData['output_dir'] ?? '');   // from task arguments (reliable)
160
        if ($backupDir === '') {
161
            $backupDir = (string)($targetDir ?? '');            // fallback if you have it
162
        }
163
164
        // keep for debug/trace
165
        $this->taskData['output_dir'] = $backupDir;
166
167
        if ($backupSource === 'scheduler' && $backupDir !== '') {
168
            $days = (int)$this->getMiscSetting('bck_scheduled_retention_days', '30');
169
            $deleted = $this->purgeOldScheduledBackups($backupDir, $days);
170
171
            $this->upsertMiscSetting('bck_scheduled_last_purge_at', (string)time());
172
            $this->upsertMiscSetting('bck_scheduled_last_purge_deleted', (string)$deleted);
173
174
            if (LOG_TASKS === true) {
175
                $this->logger->log("database_backup: purge retention={$days}d dir={$backupDir} deleted={$deleted}", 'INFO');
176
            }
177
        }
178
179
        // If launched by scheduler, update scheduler status in teampass_misc
180
        if (!empty($taskData['source']) && $taskData['source'] === 'scheduler') {
181
            $this->updateSchedulerState('completed', 'Backup created: ' . ($this->taskData['backup_file'] ?? ''));
182
        }
183
184
        if (LOG_TASKS === true) {
185
            $this->logger->log(
186
                'database_backup: created ' . ($this->taskData['backup_file'] ?? '') . ' (' . $this->taskData['backup_size_bytes'] . ' bytes)',
187
                'INFO'
188
            );
189
        }
190
    }
191
192
    /**
193
     * Mark the task as completed in the database.
194
     * This method updates the task status to 'completed' and sets the finished_at timestamp.
195
     * 
196
     * @return void
197
     */
198
    private function updateSchedulerState(string $status, string $message): void
199
    {
200
        $this->upsertMiscSetting('bck_scheduled_last_status', $status);
201
        $this->upsertMiscSetting('bck_scheduled_last_message', mb_substr($message, 0, 500));
202
        $this->upsertMiscSetting('bck_scheduled_last_completed_at', (string)time());
203
    }
204
205
    private function upsertMiscSetting(string $key, string $value): void
206
    {
207
        $table = prefixTable('misc');
208
209
        $exists = (int)DB::queryFirstField(
210
            'SELECT COUNT(*) FROM ' . $table . ' WHERE type = %s AND intitule = %s',
211
            'settings',
212
            $key
213
        );
214
215
        if ($exists > 0) {
216
            DB::update($table, ['valeur' => $value], 'type = %s AND intitule = %s', 'settings', $key);
217
        } else {
218
            DB::insert($table, ['type' => 'settings', 'intitule' => $key, 'valeur' => $value]);
219
        }
220
    }
221
222
    private function getMiscSetting(string $key, string $default = ''): string
223
    {
224
        $table = prefixTable('misc');
225
226
        $val = DB::queryFirstField(
227
            'SELECT valeur FROM ' . $table . ' WHERE type = %s AND intitule = %s LIMIT 1',
228
            'settings',
229
            $key
230
        );
231
232
        if ($val === null || $val === false || $val === '') {
233
            return $default;
234
        }
235
236
        return (string) $val;
237
    }
238
239
    private function purgeOldScheduledBackups(string $dir, int $retentionDays): int
240
    {
241
        if ($retentionDays <= 0) {
242
            return 0; // 0 => désactivé
243
        }
244
245
        if (!is_dir($dir)) {
246
            return 0;
247
        }
248
249
        $cutoff = time() - ($retentionDays * 86400);
250
        $deleted = 0;
251
252
        foreach (glob(rtrim($dir, '/') . '/scheduled-*.sql') as $file) {
253
            if (!is_file($file)) {
254
                continue;
255
            }
256
257
            $mtime = @filemtime($file);
258
            if ($mtime !== false && $mtime < $cutoff) {
259
                if (@unlink($file)) {
260
                    $deleted++;
261
                }
262
            }
263
        }
264
265
        return $deleted;
266
    }
267
268
    private function completeTask() {
269
        // Prepare data for updating the task status
270
        $updateData = [
271
            'is_in_progress' => -1,
272
            'finished_at' => time(),
273
            'status' => 'completed',
274
            'error_message' => null,   // <-- on efface toute erreur précédente
275
        ];
276
277
        // Prepare anonimzation of arguments
278
        if ($this->processType === 'send_mail') {
279
            $arguments = json_encode(
280
                [
281
                    'email' => $this->taskData['receivers'],
282
                    'login' => $this->taskData['receiver_name'],
283
                ]
284
            );
285
        } elseif ($this->processType === 'create_user_keys' || $this->processType === 'migrate_user_personal_items') {
286
            $arguments = json_encode(
287
                [
288
                    'user_id' => $this->taskData['new_user_id'],
289
                ]
290
            );
291
        } elseif ($this->processType === 'item_update_create_keys') {
292
            $arguments = json_encode(
293
                [
294
                    'item_id' => $this->taskData['item_id'],
295
                    'author' => $this->taskData['author'],
296
                ]
297
            );
298
        } elseif ($this->processType === 'database_backup') {
299
            $arguments = json_encode(
300
                [
301
                    'file' => $this->taskData['backup_file'] ?? '',
302
                    'size_bytes' => $this->taskData['backup_size_bytes'] ?? 0,
303
                    'encrypted' => $this->taskData['backup_encrypted'] ?? false,
304
                ]
305
            );
306
        } else {
307
            $arguments = '';
308
        }
309
310
        if (LOG_TASKS=== true) $this->logger->log('Process: '.$this->processType.' -- '.print_r($arguments, true), 'DEBUG');
311
312
        // Add 'arguments' only if not empty
313
        if (!empty($arguments)) {
314
            $updateData['arguments'] = $arguments;
315
        }
316
317
        // Store completed status in the database
318
        DB::update(
319
            prefixTable('background_tasks'),
320
            $updateData,
321
            'increment_id = %i',
322
            $this->taskId
323
        );
324
325
        if (LOG_TASKS=== true) $this->logger->log('Finishing task: ' . $this->taskId, 'DEBUG');
326
    }
327
328
    /**
329
     * Handle task failure by updating the task status in the database.
330
     * This method sets the task status to 'failed', updates the finished_at timestamp,
331
     * and logs the error message.
332
     * 
333
     * @param Exception $e The exception that occurred during task processing.
334
     * @return void
335
     */
336
    private function handleTaskFailure(Throwable $e) {
337
        DB::update(
338
            prefixTable('background_tasks'),
339
            [
340
                'is_in_progress' => -1,
341
                'finished_at' => time(),
342
                'status' => 'failed',
343
                'error_message' => $e->getMessage()
344
            ],
345
            'increment_id = %i',
346
            $this->taskId
347
        );
348
        $this->logger->log('Task failure: ' . $e->getMessage(), 'ERROR');
349
    // Purge retention even on failure (safe: only scheduled-*.sql)
350
        $backupDir = (string)($this->taskData['output_dir'] ?? '');
351
        if ($backupDir !== '' && is_dir($backupDir)) {
352
        $days = (int)$this->getMiscSetting('bck_scheduled_retention_days', '30');
353
        $deleted = $this->purgeOldScheduledBackups($backupDir, $days);
354
355
        $this->upsertMiscSetting('bck_scheduled_last_purge_at', (string)time());
356
        $this->upsertMiscSetting('bck_scheduled_last_purge_deleted', (string)$deleted);
357
        }
358
    }
359
360
    /**
361
     * Handle subtasks for the current task.
362
     * This method retrieves all subtasks related to the current task and processes them.
363
     * If all subtasks are completed, it marks the main task as completed.
364
     * 
365
     * @param array $arguments Arguments for the subtasks.
366
     * @return void
367
     */
368
    private function processSubTasks($arguments) {
369
        if (LOG_TASKS=== true) $this->logger->log('processSubTasks: '.print_r($arguments, true), 'DEBUG');
370
        // Get all subtasks related to this task
371
        $subtasks = DB::query(
372
            'SELECT * FROM ' . prefixTable('background_subtasks') . ' WHERE task_id = %i AND is_in_progress = 0 ORDER BY `task` ASC',
373
            $this->taskId
374
        );
375
    
376
        // Check if there are any subtasks to process
377
        if (empty($subtasks)) {
378
            if (LOG_TASKS=== true) $this->logger->log('No subtask was found for task: ' . $this->taskId, 'DEBUG');
379
            return;
380
        }
381
    
382
        // Process each subtask
383
        foreach ($subtasks as $subtask) {
384
            try {
385
                // Get the subtask data
386
                $subtaskData = json_decode($subtask['task'], true);
387
388
                if (LOG_TASKS=== true) $this->logger->log('Processing subtask: ' . $subtaskData['step'], 'DEBUG');
389
390
                // Mark subtask as in progress
391
                DB::update(
392
                    prefixTable('background_tasks'),
393
                    ['updated_at' => time()],
394
                    'increment_id = %i',
395
                    $this->taskId
396
                );
397
398
                // Process the subtask based on its type
399
                switch ($subtaskData['step'] ?? '') {
400
                    case 'create_users_pwd_key':
401
                        $this->generateUserPasswordKeys($arguments);
402
                        break;
403
                    case 'create_users_fields_key':
404
                        $this->generateUserFieldKeys($subtaskData);
405
                        break;
406
                    case 'create_users_files_key':
407
                        $this->generateUserFileKeys($subtaskData);
408
                        break;
409
                    default:
410
                        throw new Exception("Type de sous-tâche inconnu (".$subtaskData['step'].")");
411
                }                
412
        
413
                // Mark subtask as completed
414
                DB::update(
415
                    prefixTable('background_subtasks'),
416
                    [
417
                        'is_in_progress' => -1,
418
                        'finished_at' => time(),
419
                        'status' => 'completed',
420
                    ],
421
                    'increment_id = %i',
422
                    $subtask['increment_id']
423
                );
424
        
425
            } catch (Exception $e) {
426
                // Mark subtask as failed
427
                DB::update(
428
                    prefixTable('background_subtasks'),
429
                    [
430
                        'is_in_progress' => -1,
431
                        'finished_at' => time(),
432
                        'updated_at' => time(),
433
                        'status' => 'failed',
434
                        'error_message' => $e->getMessage(),
435
                    ],
436
                    'increment_id = %i',
437
                    $subtask['increment_id']
438
                );
439
        
440
                $this->logger->log('processSubTasks : ' . $e->getMessage(), 'ERROR');
441
            }
442
        }
443
    
444
        // Are all subtasks completed?
445
        $remainingSubtasks = DB::queryFirstField(
446
            'SELECT COUNT(*) FROM ' . prefixTable('background_subtasks') . ' WHERE task_id = %i AND is_in_progress = 0',
447
            $this->taskId
448
        );
449
    
450
        if ($remainingSubtasks == 0) {
451
            $this->completeTask();
452
        }
453
    }
454
}
455
456
// Prepare the environment
457
// Get the task ID and process type from command line arguments
458
if ($argc < 3) {
459
    error_log("Usage: php background_tasks___worker.php <task_id> <process_type> [<task_data>]");
460
    exit(1);
461
}
462
$taskId = (int)$argv[1];
463
$processType = $argv[2];
464
$taskData = $argv[3] ?? null;
465
if ($taskData) {
466
    $taskData = json_decode($taskData, true);
467
    if (!is_array($taskData)) {
468
        $taskData = [];
469
    }
470
} else {
471
    $taskData = [];
472
}
473
474
// Initialize the worker
475
$worker = new TaskWorker($taskId, $processType, $taskData);
476
$worker->execute();
477