Passed
Push — master ( d7fbc5...7b4259 )
by Nils
07:19
created

BackgroundTasksHandler::markTaskFailed()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 9
c 0
b 0
f 0
nc 2
nop 2
dl 0
loc 13
rs 9.9666
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___handler.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 Symfony\Component\Process\Process;
30
use TeampassClasses\ConfigManager\ConfigManager;
31
32
require_once __DIR__.'/../sources/main.functions.php';
33
require_once __DIR__ . '/taskLogger.php';
34
35
class BackgroundTasksHandler {
36
    private $settings;
37
    private $logger;
38
    private $maxParallelTasks;
39
    private $maxExecutionTime;
40
    private $batchSize;
41
    private $maxTimeBeforeRemoval;
42
    private $lockFileHandle = null;
43
    /** @var array Running process pool: [taskId => ['process' => Process, 'task' => array, 'resourceKey' => ?string]] */
44
    private $pool = [];
45
46
    public function __construct(array $settings) {
47
        $this->settings = $settings;
48
        $this->logger = new TaskLogger($settings, LOG_TASKS_FILE);
49
        $this->maxParallelTasks = $settings['max_parallel_tasks'] ?? 2;
50
        $this->maxExecutionTime = $settings['task_maximum_run_time'] ?? 600;
51
        $this->batchSize = $settings['task_batch_size'] ?? 50;
52
        // Tasks history retention (seconds)
53
        // Prefer new setting `tasks_history_delay` (stored in seconds in DB),
54
        // fallback to legacy `history_duration` (days) if present, otherwise 15 days.
55
        $historyDelay = 0;
56
57
        if (isset($settings['tasks_history_delay']) === true) {
58
            $historyDelay = (int) $settings['tasks_history_delay'];
59
        } elseif (isset($settings['history_duration']) === true) {
60
            $historyDelay = (int) $settings['history_duration'] * 86400;
61
        }
62
63
        // Safety: if for any reason it is stored in days, convert to seconds.
64
        if ($historyDelay > 0 && $historyDelay < 86400) {
65
            $historyDelay = $historyDelay * 86400;
66
        }
67
68
        $this->maxTimeBeforeRemoval = $historyDelay > 0 ? $historyDelay : (15 * 86400);
69
70
    }
71
72
    /**
73
     * Main function to process background tasks
74
     */
75
    public function processBackgroundTasks() {
76
        // Prevent multiple concurrent executions
77
        if (!$this->acquireProcessLock()) {
78
            if (LOG_TASKS === true) $this->logger->log('Process already running', 'INFO');
79
            return false;
80
        }
81
82
        try {
83
            $this->cleanupStaleTasks();
84
            $this->handleScheduledDatabaseBackup();
85
            $this->drainTaskPool();
86
            $this->performMaintenanceTasks();
87
        } catch (Exception $e) {
88
            if (LOG_TASKS === true) $this->logger->log('Task processing error: ' . $e->getMessage(), 'ERROR');
89
        } finally {
90
            $this->stopRunningProcesses();
91
            $this->releaseProcessLock();
92
        }
93
    }
94
    /**
95
     * Scheduler: enqueue a database_backup task when due.
96
     */
97
    private function handleScheduledDatabaseBackup(): void
98
    {
99
        $enabled = (int)$this->getSettingValue('bck_scheduled_enabled', '0');
100
        if ($enabled !== 1) {
101
            return;
102
        }
103
104
        $now = time();
105
106
        // Output dir
107
        $outputDir = (string)$this->getSettingValue('bck_scheduled_output_dir', '');
108
        if ($outputDir === '') {
109
            $baseFilesDir = (string)($this->settings['path_to_files_folder'] ?? (__DIR__ . '/../files'));
110
            $outputDir = rtrim($baseFilesDir, '/') . '/backups';
111
            $this->upsertSettingValue('bck_scheduled_output_dir', $outputDir);
112
        }
113
114
        // next_run_at
115
        $nextRunAt = (int)$this->getSettingValue('bck_scheduled_next_run_at', '0');
116
        if ($nextRunAt <= 0) {
117
            $nextRunAt = $this->computeNextBackupRunAt($now);
118
            $this->upsertSettingValue('bck_scheduled_next_run_at', (string)$nextRunAt);
119
            if (LOG_TASKS === true) $this->logger->log('backup scheduler initialized next_run_at=' . $nextRunAt, 'INFO');
120
            return;
121
        }
122
123
        if ($now < $nextRunAt) {
124
            return;
125
        }
126
127
        // Avoid duplicates: if a database_backup task is already pending or running, skip.
128
        $pending = (int)DB::queryFirstField(
129
            'SELECT COUNT(*)
130
            FROM ' . prefixTable('background_tasks') . '
131
            WHERE process_type = %s
132
            AND is_in_progress IN (0,1)
133
            AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)',
134
            'database_backup'
135
        );
136
137
        if ($pending > 0) {
138
            if (LOG_TASKS === true) $this->logger->log('backup scheduler: a database_backup task is already pending/running', 'INFO');
139
            return;
140
        }
141
142
        // Enqueue task
143
        DB::insert(
144
            prefixTable('background_tasks'),
145
            [
146
                'created_at' => (string)$now,
147
                'process_type' => 'database_backup',
148
                'arguments' => json_encode(
149
                    [
150
                        'output_dir' => $outputDir,
151
                        'source' => 'scheduler',
152
                    ],
153
                    JSON_UNESCAPED_SLASHES
154
                ),
155
                'is_in_progress' => 0,
156
                'status' => 'new',
157
            ]
158
        );
159
160
    $this->upsertSettingValue('bck_scheduled_last_run_at', (string)$now);
161
    $this->upsertSettingValue('bck_scheduled_last_status', 'queued');
162
    $this->upsertSettingValue('bck_scheduled_last_message', 'Task enqueued by scheduler');
163
164
        // Compute next run
165
        $newNext = $this->computeNextBackupRunAt($now + 60);
166
        $this->upsertSettingValue('bck_scheduled_next_run_at', (string)$newNext);
167
168
        if (LOG_TASKS === true) $this->logger->log('backup scheduler: enqueued database_backup, next_run_at=' . $newNext, 'INFO');
169
    }
170
171
    /**
172
     * Compute next run timestamp based on settings:
173
     * - bck_scheduled_frequency: daily|weekly|monthly (default daily)
174
     * - bck_scheduled_time: HH:MM (default 02:00)
175
     * - bck_scheduled_dow: 1..7 (ISO, Mon=1) for weekly (default 1)
176
     * - bck_scheduled_dom: 1..31 for monthly (default 1)
177
     */
178
    
179
    private function getTeampassTimezoneName(): string
180
{
181
    // TeamPass stores timezone in teampass_misc: type='admin', intitule='timezone'
182
    $tz = DB::queryFirstField(
183
        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE type = %s AND intitule = %s LIMIT 1',
184
        'admin',
185
        'timezone'
186
    );
187
188
    return (is_string($tz) && $tz !== '') ? $tz : 'UTC';
189
}
190
    
191
    private function computeNextBackupRunAt(int $fromTs): int
192
    {
193
        // On se base sur la timezone PHP du serveur (simple et robuste)
194
        $tzName = $this->getTeampassTimezoneName();
195
        try {
196
            $tz = new DateTimeZone($tzName);
197
        } catch (Throwable $e) {
198
            $tz = new DateTimeZone('UTC');
199
        }
200
201
        $freq = (string)$this->getSettingValue('bck_scheduled_frequency', 'daily');
202
        $timeStr = (string)$this->getSettingValue('bck_scheduled_time', '02:00');
203
204
        if (!preg_match('/^\d{2}:\d{2}$/', $timeStr)) {
205
            $timeStr = '02:00';
206
        }
207
        [$hh, $mm] = array_map('intval', explode(':', $timeStr));
208
209
        $now = (new DateTimeImmutable('@' . $fromTs))->setTimezone($tz);
210
        $candidate = $now->setTime($hh, $mm, 0);
211
212
        if ($freq === 'weekly') {
213
            $targetDow = (int)$this->getSettingValue('bck_scheduled_dow', '1'); // ISO 1..7
214
            if ($targetDow < 1 || $targetDow > 7) $targetDow = 1;
215
216
            $currentDow = (int)$candidate->format('N');
217
            $delta = ($targetDow - $currentDow + 7) % 7;
218
            if ($delta === 0 && $candidate <= $now) {
219
                $delta = 7;
220
            }
221
            $candidate = $candidate->modify('+' . $delta . ' days');
222
223
        } elseif ($freq === 'monthly') {
224
            $dom = (int)$this->getSettingValue('bck_scheduled_dom', '1');
225
            if ($dom < 1) $dom = 1;
226
            if ($dom > 31) $dom = 31;
227
228
            $year = (int)$now->format('Y');
229
            $month = (int)$now->format('m');
230
            $daysInMonth = (int)$now->format('t');
231
            $day = min($dom, $daysInMonth);
232
233
            $candidate = $now->setDate($year, $month, $day)->setTime($hh, $mm, 0);
234
            if ($candidate <= $now) {
235
                $nextMonth = $now->modify('first day of next month');
236
                $year2 = (int)$nextMonth->format('Y');
237
                $month2 = (int)$nextMonth->format('m');
238
                $daysInMonth2 = (int)$nextMonth->format('t');
239
                $day2 = min($dom, $daysInMonth2);
240
241
                $candidate = $nextMonth->setDate($year2, $month2, $day2)->setTime($hh, $mm, 0);
242
            }
243
244
        } else {
245
            // daily
246
            if ($candidate <= $now) {
247
                $candidate = $candidate->modify('+1 day');
248
            }
249
        }
250
251
        return $candidate->getTimestamp();
252
    }
253
254
    /**
255
     * Read a setting from teampass_misc (type='settings', intitule=key).
256
     */
257
    private function getSettingValue(string $key, string $default = ''): string
258
    {
259
        $table = prefixTable('misc');
260
261
        // Schéma TeamPass classique: misc(type, intitule, valeur)
262
        $val = DB::queryFirstField(
263
            'SELECT valeur FROM ' . $table . ' WHERE type = %s AND intitule = %s LIMIT 1',
264
            'settings',
265
            $key
266
        );
267
268
        if ($val === null || $val === false || $val === '') {
269
            return $default;
270
        }
271
272
        return (string)$val;
273
    }
274
275
    /**
276
     * Upsert a setting into teampass_misc (type='settings', intitule=key).
277
     */
278
    private function upsertSettingValue(string $key, string $value): void
279
    {
280
        $table = prefixTable('misc');
281
282
        $exists = (int)DB::queryFirstField(
283
            'SELECT COUNT(*) FROM ' . $table . ' WHERE type = %s AND intitule = %s',
284
            'settings',
285
            $key
286
        );
287
288
        if ($exists > 0) {
289
            DB::update($table, ['valeur' => $value], 'type = %s AND intitule = %s', 'settings', $key);
290
        } else {
291
            DB::insert($table, ['type' => 'settings', 'intitule' => $key, 'valeur' => $value]);
292
        }
293
294
        // keep in memory too
295
        $this->settings[$key] = $value;
296
    }
297
298
    /**
299
     * Acquire a lock to prevent multiple instances of this script from running simultaneously.
300
     * @return bool
301
     */
302
    private function acquireProcessLock(): bool {
303
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
304
305
        $fp = fopen($lockFile, 'w');
306
        if ($fp === false) {
307
            return false;
308
        }
309
310
        if (!flock($fp, LOCK_EX | LOCK_NB)) {
311
            fclose($fp);
312
            return false;
313
        }
314
315
        fwrite($fp, (string)getmypid());
316
        $this->lockFileHandle = $fp;
317
        return true;
318
    }
319
320
    /**
321
     * Release the lock file.
322
     */
323
    private function releaseProcessLock() {
324
        if ($this->lockFileHandle !== null) {
325
            flock($this->lockFileHandle, LOCK_UN);
326
            fclose($this->lockFileHandle);
327
            $this->lockFileHandle = null;
328
        }
329
330
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
331
        if (file_exists($lockFile)) {
332
            unlink($lockFile);
333
        }
334
    }
335
336
    /**
337
     * Cleanup stale tasks that have been running for too long or are marked as failed.
338
     */
339
    private function cleanupStaleTasks() {
340
        // Mark tasks as failed if they've been running too long
341
        DB::query(
342
            'UPDATE ' . prefixTable('background_tasks') . ' 
343
            SET is_in_progress = -1, 
344
                finished_at = %i, 
345
                status = "failed",
346
                error_message = "Task exceeded maximum execution time of '.$this->maxExecutionTime.' seconds"
347
            WHERE is_in_progress = 1 
348
            AND updated_at < %i',
349
            time(),
350
            time() - $this->maxExecutionTime
351
        );
352
353
        // Remove very old failed tasks
354
        DB::query(
355
            'DELETE t, st FROM ' . prefixTable('background_tasks') . ' t
356
            INNER JOIN ' . prefixTable('background_subtasks') . ' st ON (t.increment_id = st.task_id)
357
            WHERE t.finished_at > 0
358
              AND t.finished_at < %i
359
              AND t.status = %s',
360
            time() - $this->maxTimeBeforeRemoval,
361
            'failed'
362
        );
363
    }
364
365
    /**
366
     * Drain the task pool: launch tasks in parallel, poll for completion,
367
     * refill slots, and repeat until no pending tasks remain or time limit is reached.
368
     */
369
    private function drainTaskPool(): void {
370
        $startTime = time();
371
        $maxDrainTime = (int)($this->settings['tasks_max_drain_time'] ?? 55);
372
        $launchingEnabled = true;
373
374
        while (true) {
375
            // 1. Poll completed processes
376
            $this->pollCompletedProcesses();
377
378
            // 2. Fill slots with new tasks (if still within drain time)
379
            if ($launchingEnabled) {
380
                $this->fillPoolSlots();
381
            }
382
383
            // 3. Nothing running and nothing to launch → done
384
            if (empty($this->pool)) {
385
                if (!$launchingEnabled || $this->countPendingTasks() === 0) {
386
                    break;
387
                }
388
            }
389
390
            // 4. Check drain time limit (stop launching, but wait for running processes)
391
            if ($launchingEnabled && (time() - $startTime) >= $maxDrainTime) {
392
                $launchingEnabled = false;
393
                if (LOG_TASKS === true) {
394
                    $pending = $this->countPendingTasks();
395
                    $this->logger->log(
396
                        "Drain time limit reached ({$maxDrainTime}s), waiting for "
397
                        . count($this->pool) . " running, {$pending} pending deferred",
398
                        'INFO'
399
                    );
400
                }
401
                if (empty($this->pool)) break;
402
            }
403
404
            // 5. Short pause before next poll
405
            usleep(100000); // 100ms
406
        }
407
    }
408
409
    /**
410
     * Poll pool for completed or timed-out processes and handle their results.
411
     */
412
    private function pollCompletedProcesses(): void {
413
        foreach ($this->pool as $taskId => $entry) {
414
            $process = $entry['process'];
415
416
            // Check per-process timeout
417
            try {
418
                $process->checkTimeout();
419
            } catch (Throwable $e) {
420
                $this->markTaskFailed($taskId, 'Process timeout: ' . $e->getMessage());
421
                try { $process->stop(5); } catch (Throwable $ignored) {}
0 ignored issues
show
introduced by
Consider moving this CATCH statement to a new line.
Loading history...
422
                unset($this->pool[$taskId]);
423
                continue;
424
            }
425
426
            // Check if process finished
427
            if (!$process->isRunning()) {
428
                $this->handleProcessCompletion($entry);
429
                unset($this->pool[$taskId]);
430
            }
431
        }
432
    }
433
434
    /**
435
     * Fill available pool slots with compatible pending tasks.
436
     * Respects exclusive task rules and resource key conflict detection.
437
     */
438
    private function fillPoolSlots(): void {
439
        $maxPool = min((int) $this->maxParallelTasks, 2);
440
        $availableSlots = $maxPool - count($this->pool);
441
        if ($availableSlots <= 0) return;
442
443
        // Don't launch anything while an exclusive task is running
444
        foreach ($this->pool as $entry) {
445
            if ($this->isExclusiveTask($entry['task']['process_type'])) {
446
                return;
447
            }
448
        }
449
450
        // Collect resource keys of running tasks
451
        $runningKeys = [];
452
        foreach ($this->pool as $entry) {
453
            if ($entry['resourceKey'] !== null) {
454
                $runningKeys[] = $entry['resourceKey'];
455
            }
456
        }
457
458
        // Fetch candidate tasks from DB
459
        $candidates = DB::query(
460
            'SELECT increment_id, process_type, arguments
461
            FROM ' . prefixTable('background_tasks') . '
462
            WHERE is_in_progress = 0
463
            AND (finished_at IS NULL OR finished_at = "")
464
            ORDER BY increment_id ASC
465
            LIMIT %i',
466
            $this->batchSize
467
        );
468
469
        foreach ($candidates as $task) {
470
            if ($availableSlots <= 0) break;
471
472
            $isExclusive = $this->isExclusiveTask($task['process_type']);
473
            $resourceKey = $this->getResourceKey($task);
474
475
            // Exclusive task: only launch if pool is completely empty
476
            if ($isExclusive && !empty($this->pool)) {
477
                continue;
478
            }
479
480
            // Resource key conflict: skip if same key is already running
481
            if ($resourceKey !== null && in_array($resourceKey, $runningKeys, true)) {
482
                if (LOG_TASKS === true) $this->logger->log(
483
                    'Task ' . $task['increment_id'] . ' deferred: resource conflict (' . $resourceKey . ')',
484
                    'INFO'
485
                );
486
                continue;
487
            }
488
489
            // Launch the task
490
            if (LOG_TASKS === true) $this->logger->log(
491
                'Launching task ' . $task['increment_id'] . ' (' . $task['process_type'] . ')',
492
                'INFO'
493
            );
494
495
            $process = $this->launchTask($task);
496
            if ($process !== null) {
497
                $this->pool[(int) $task['increment_id']] = [
498
                    'process' => $process,
499
                    'task' => $task,
500
                    'resourceKey' => $resourceKey,
501
                ];
502
                if ($resourceKey !== null) {
503
                    $runningKeys[] = $resourceKey;
504
                }
505
                $availableSlots--;
506
507
                // If we just launched an exclusive task, stop filling
508
                if ($isExclusive) break;
509
            }
510
        }
511
    }
512
513
    /**
514
     * Launch a task as a non-blocking subprocess.
515
     * Returns the Process object on success, null if the task was handled via fallback or failed.
516
     *
517
     * @param array $task Task row from the database.
518
     * @return Process|null
519
     */
520
    private function launchTask(array $task): ?Process {
521
        // Mark task as in progress
522
        DB::update(
523
            prefixTable('background_tasks'),
524
            [
525
                'is_in_progress' => 1,
526
                'started_at' => time(),
527
                'updated_at' => time(),
528
                'status' => 'in_progress'
529
            ],
530
            'increment_id = %i',
531
            $task['increment_id']
532
        );
533
534
        // Build command
535
        $cmd = sprintf(
536
            '%s %s %d %s %s',
537
            escapeshellarg(PHP_BINARY),
538
            escapeshellarg(__DIR__ . '/background_tasks___worker.php'),
539
            (int) $task['increment_id'],
540
            escapeshellarg((string) $task['process_type']),
541
            escapeshellarg((string) $task['arguments'])
542
        );
543
544
        $process = Process::fromShellCommandline($cmd);
545
        $process->setTimeout($this->maxExecutionTime);
546
547
        try {
548
            $process->start();
549
            return $process;
550
        } catch (Throwable $e) {
551
            // Symfony Process failed to start, try exec() fallback (blocking)
552
            if (LOG_TASKS === true) $this->logger->log(
553
                'Process::start() failed for task ' . $task['increment_id'] . ': ' . $e->getMessage() . ', trying exec fallback',
554
                'WARNING'
555
            );
556
557
            $out = [];
558
            $rc = 0;
559
            exec($cmd . ' 2>&1', $out, $rc);
560
561
            if ($rc === 0) {
562
                // Worker ran successfully via fallback and updated the DB itself
563
                if (LOG_TASKS === true) $this->logger->log(
564
                    'Fallback exec succeeded for task ' . $task['increment_id'],
565
                    'INFO'
566
                );
567
                return null; // Already completed, nothing to poll
568
            }
569
570
            $msg = $e->getMessage()
571
                . ' | fallback_exit=' . $rc
572
                . ' | fallback_out=' . implode("\n", array_slice($out, -30));
573
574
            $this->markTaskFailed((int) $task['increment_id'], $msg);
575
            return null;
576
        }
577
    }
578
579
    /**
580
     * Handle a completed process: check exit status and update DB if the worker didn't.
581
     *
582
     * @param array $entry Pool entry with 'process', 'task', and 'resourceKey'.
583
     */
584
    private function handleProcessCompletion(array $entry): void {
585
        $process = $entry['process'];
586
        $taskId = (int) $entry['task']['increment_id'];
587
588
        if ($process->isSuccessful()) {
589
            if (LOG_TASKS === true) $this->logger->log('Task ' . $taskId . ' completed successfully', 'INFO');
590
            return;
591
        }
592
593
        // Process exited with error - check if worker already updated the DB
594
        $currentStatus = DB::queryFirstField(
595
            'SELECT status FROM ' . prefixTable('background_tasks') . ' WHERE increment_id = %i',
596
            $taskId
597
        );
598
599
        // Worker may have already handled the failure via handleTaskFailure()
600
        if ($currentStatus !== 'in_progress') {
601
            if (LOG_TASKS === true) $this->logger->log(
602
                'Task ' . $taskId . ' process failed (exit ' . $process->getExitCode() . ') but worker already set status=' . $currentStatus,
603
                'INFO'
604
            );
605
            return;
606
        }
607
608
        // Worker didn't update - mark as failed from handler side
609
        $msg = 'Process exited with code ' . $process->getExitCode();
610
        $stderr = $process->getErrorOutput();
611
        if (!empty($stderr)) {
612
            $msg .= ': ' . mb_substr($stderr, -500);
613
        }
614
        $this->markTaskFailed($taskId, $msg);
615
    }
616
617
    /**
618
     * Mark a task as failed in the database.
619
     *
620
     * @param int $taskId Task ID.
621
     * @param string $message Error message.
622
     */
623
    private function markTaskFailed(int $taskId, string $message): void {
624
        DB::update(
625
            prefixTable('background_tasks'),
626
            [
627
                'is_in_progress' => -1,
628
                'finished_at' => time(),
629
                'status' => 'failed',
630
                'error_message' => mb_substr($message, 0, 1000)
631
            ],
632
            'increment_id = %i',
633
            $taskId
634
        );
635
        if (LOG_TASKS === true) $this->logger->log('Task ' . $taskId . ' failed: ' . $message, 'ERROR');
636
    }
637
638
    /**
639
     * Gracefully stop all processes remaining in the pool (safety net for shutdown).
640
     */
641
    private function stopRunningProcesses(): void {
642
        foreach ($this->pool as $taskId => $entry) {
643
            $process = $entry['process'];
644
            if ($process->isRunning()) {
645
                try {
646
                    $process->stop(10);
647
                } catch (Throwable $e) {
648
                    // best effort
649
                }
650
                $this->markTaskFailed($taskId, 'Handler shutdown: process forcibly stopped');
651
            }
652
        }
653
        $this->pool = [];
654
    }
655
656
    /**
657
     * Count the number of currently running tasks.
658
     * @return int The number of running tasks.
659
     */
660
    private function countRunningTasks(): int {
0 ignored issues
show
Unused Code introduced by
The method countRunningTasks() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
661
        return DB::queryFirstField(
662
            'SELECT COUNT(*)
663
            FROM ' . prefixTable('background_tasks') . '
664
            WHERE is_in_progress = 1'
665
        );
666
    }
667
668
    /**
669
     * Count the number of pending tasks (not started and not finished).
670
     * @return int The number of pending tasks.
671
     */
672
    private function countPendingTasks(): int {
673
        return (int) DB::queryFirstField(
674
            'SELECT COUNT(*)
675
            FROM ' . prefixTable('background_tasks') . '
676
            WHERE is_in_progress = 0
677
            AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)'
678
        );
679
    }
680
681
    /**
682
     * Determine whether a task type must run exclusively (no other task in parallel).
683
     * Exclusive tasks perform bulk operations across many rows or have global side-effects.
684
     *
685
     * @param string $processType The process_type value from background_tasks.
686
     * @return bool
687
     */
688
    private function isExclusiveTask(string $processType): bool {
689
        return in_array($processType, [
690
            'create_user_keys',            // bulk delete + regenerate all sharekeys for a user
691
            'phpseclibv3_migration',       // iterates all sharekeys for a user
692
            'migrate_user_personal_items', // modifies personal item sharekeys
693
            'database_backup',             // disconnects users, heavy I/O
694
        ], true);
695
    }
696
697
    /**
698
     * Extract a resource key from a task for conflict detection.
699
     * Two tasks with the same resource key must not run in parallel.
700
     * Returns null for exclusive tasks (handled separately) and for
701
     * independent tasks that never conflict.
702
     *
703
     * @param array $task Task row with 'process_type' and 'arguments'.
704
     * @return string|null Resource key or null if not applicable.
705
     */
706
    private function getResourceKey(array $task): ?string {
707
        // Exclusive tasks are handled by isExclusiveTask(), no key needed
708
        if ($this->isExclusiveTask($task['process_type'])) {
709
            return null;
710
        }
711
712
        $args = json_decode($task['arguments'] ?? '', true);
713
        if (!is_array($args)) {
714
            $args = [];
715
        }
716
717
        switch ($task['process_type']) {
718
            case 'new_item':
719
            case 'item_copy':
720
            case 'update_item':
721
            case 'item_update_create_keys':
722
                return 'item:' . ($args['item_id'] ?? 0);
723
724
            case 'user_build_cache_tree':
725
                return 'user:' . ($args['user_id'] ?? 0);
726
727
            case 'send_email':
728
                // Emails never conflict with each other
729
                return null;
730
731
            default:
732
                // Unknown type: use task ID as unique key (never conflicts)
733
                return null;
734
        }
735
    }
736
737
    /**
738
     * Perform maintenance tasks.
739
     * This method cleans up old items, expired tokens, and finished tasks.
740
     */
741
    private function performMaintenanceTasks() {
742
        $this->cleanMultipleItemsEdition();
743
        $this->handleItemTokensExpiration();
744
        $this->cleanOldFinishedTasks();
745
        $this->cleanOldImportFiles();
746
    }
747
748
    /**
749
     * Clean up multiple items edition.
750
     * This method removes duplicate entries in the items_edition table.
751
     */
752
    private function cleanMultipleItemsEdition() {
753
        DB::query(
754
            'DELETE i1 FROM ' . prefixTable('items_edition') . ' i1
755
            JOIN (
756
                SELECT user_id, item_id, MIN(timestamp) AS oldest_timestamp
757
                FROM ' . prefixTable('items_edition') . '
758
                GROUP BY user_id, item_id
759
            ) i2 ON i1.user_id = i2.user_id AND i1.item_id = i2.item_id
760
            WHERE i1.timestamp > i2.oldest_timestamp'
761
        );
762
    }
763
764
    /**
765
     * Handle item tokens expiration.
766
     * This method removes expired tokens from the items_edition table.
767
     */
768
    private function handleItemTokensExpiration() {
769
        DB::query(
770
            'DELETE FROM ' . prefixTable('items_edition') . '
771
            WHERE timestamp < %i',
772
            time() - ($this->settings['delay_item_edition'] * 60 ?: EDITION_LOCK_PERIOD)
773
        );
774
    }
775
776
    /**
777
     * Clean up old finished tasks.
778
     * This method removes tasks that have been completed for too long.
779
     */
780
    private function cleanOldFinishedTasks() {
781
        // Timestamp cutoff for removal
782
        $cutoffTimestamp = time() - $this->maxTimeBeforeRemoval;
783
    
784
        // 1. Get all finished tasks older than the cutoff timestamp
785
        //    and that are not in progress
786
        $tasks = DB::query(
787
            'SELECT increment_id FROM ' . prefixTable('background_tasks') . '
788
            WHERE status = %s AND is_in_progress = %i AND finished_at < %i',
789
            'completed',
790
            -1,
791
            $cutoffTimestamp
792
        );
793
        
794
        if (empty($tasks)) {
795
            return;
796
        }
797
    
798
        $taskIds = array_column($tasks, 'increment_id');
799
    
800
        // 2. Delete all subtasks related to these tasks
801
        DB::query(
802
            'DELETE FROM ' . prefixTable('background_subtasks') . '
803
            WHERE task_id IN %ls',
804
            $taskIds
805
        );
806
    
807
        // 3. Delete the tasks themselves
808
        DB::query(
809
            'DELETE FROM ' . prefixTable('background_tasks') . '
810
            WHERE increment_id IN %ls',
811
            $taskIds
812
        );
813
    
814
        if (LOG_TASKS=== true) $this->logger->log('Old finished tasks cleaned: ' . count($taskIds), 'INFO');
815
    }
816
817
    /**
818
     * Clean old temporary import files that were not deleted after failed imports
819
     * Removes files older than a specified threshold from both filesystem and database
820
     *
821
     * @return void
822
     */
823
    private function cleanOldImportFiles(): void
824
    {
825
        try {
826
            // Define threshold for old files (1 hour by default)
827
            $thresholdTimestamp = time() - (1 * 3600);
828
            
829
            // Statistics for logging
830
            $stats = [
831
                'total_found' => 0,
832
                'files_deleted' => 0,
833
                'db_entries_deleted' => 0,
834
                'errors' => 0
835
            ];
836
837
            // Retrieve all temp_file entries from database
838
            $tempFiles = DB::query(
839
                'SELECT increment_id, intitule, valeur 
840
                FROM %l 
841
                WHERE type = %s',
842
                'teampass_misc',
843
                'temp_file'
844
            );
845
846
            $stats['total_found'] = count($tempFiles);
847
848
            if (empty($tempFiles)) {
849
                if (LOG_TASKS=== true) $this->logger->log('TeamPass Background Task: No temporary import files found in database', 'INFO');
850
                return;
851
            }
852
853
            foreach ($tempFiles as $fileEntry) {
854
                $entryId = (int) $fileEntry['increment_id'];
855
                $timestamp = (int) $fileEntry['intitule'];
856
                $fileName = (string) $fileEntry['valeur'];
857
858
                // Validate timestamp format
859
                if ($timestamp <= 0) {
860
                    if (LOG_TASKS=== true) $this->logger->log("TeamPass Background Task: Invalid timestamp for temp_file entry ID {$entryId}: {$timestamp}", 'INFO');
861
                    $stats['errors']++;
862
                    continue;
863
                }
864
865
                // Check if file is old enough to be deleted
866
                if ($timestamp > $thresholdTimestamp) {
867
                    // File is still recent, skip deletion
868
                    continue;
869
                }
870
871
                // Construct full file path
872
                $filePath = './files/' . $fileName;
873
874
                // Attempt to delete file from filesystem
875
                $fileDeleted = $this->deleteImportFile($filePath, $entryId);
876
                
877
                if ($fileDeleted) {
878
                    $stats['files_deleted']++;
879
                }
880
881
                // Delete database entry regardless of file deletion result
882
                // (file might already be deleted manually)
883
                try {
884
                    DB::delete(
885
                        'teampass_misc',
886
                        'increment_id = %i',
887
                        $entryId
888
                    );
889
                    $stats['db_entries_deleted']++;
890
                } catch (Exception $e) {
891
                    if (LOG_TASKS=== true) $this->logger->log(
892
                        "TeamPass Background Task: Failed to delete temp_file entry ID {$entryId} from database: " . $e->getMessage(),
893
                        'INFO'
894
                    );
895
                    $stats['errors']++;
896
                }
897
            }
898
899
            // Log final statistics
900
            if (LOG_TASKS=== true) $this->logger->log(
901
                sprintf(
902
                    'TeamPass Background Task: Import files cleanup completed - Found: %d, Files deleted: %d, DB entries deleted: %d, Errors: %d',
903
                    $stats['total_found'],
904
                    $stats['files_deleted'],
905
                    $stats['db_entries_deleted'],
906
                    $stats['errors']
907
                )
908
                , 'INFO'
909
            );
910
911
        } catch (Exception $e) {
912
            if (LOG_TASKS=== true) $this->logger->log(
913
                "TeamPass Background Task: Critical error in cleanOldImportFiles(): " . $e->getMessage(),
914
                'INFO'
915
            );
916
        }
917
    }
918
919
    /**
920
     * Delete a single import file with proper error handling
921
     *
922
     * @param string $filePath Path to the file to delete
923
     * @param int $entryId Database entry ID (for logging purposes)
924
     * @return bool True if file was deleted successfully or doesn't exist, false on error
925
     */
926
    private function deleteImportFile(string $filePath, int $entryId): bool
927
    {
928
        // Check if file exists
929
        if (!file_exists($filePath)) {
930
            // File already deleted or never existed, consider as success
931
            return true;
932
        }
933
934
        // Verify it's actually a file (not a directory)
935
        if (!is_file($filePath)) {
936
            if (LOG_TASKS=== true) $this->logger->log(
937
                "TeamPass Background Task: Path is not a file (entry ID {$entryId}): {$filePath}",
938
                'INFO'
939
            );
940
            return false;
941
        }
942
943
        // Check write permissions
944
        if (!is_writable($filePath)) {
945
            if (LOG_TASKS=== true) $this->logger->log(
946
                "TeamPass Background Task: File is not writable, cannot delete (entry ID {$entryId}): {$filePath}",
947
                'INFO'
948
            );
949
            return false;
950
        }
951
952
        // Attempt deletion
953
        if (unlink($filePath) === false) {
954
            if (LOG_TASKS=== true) $this->logger->log(
955
                "TeamPass Background Task: Failed to delete file (entry ID {$entryId}): {$filePath}",
956
                'INFO'
957
            );
958
            return false;
959
        }
960
961
        return true;
962
    }
963
}
964
965
966
967
// Main execution
968
try {
969
    $configManager = new ConfigManager();
970
    $settings = $configManager->getAllSettings();
971
    
972
    $tasksHandler = new BackgroundTasksHandler($settings);
973
    $tasksHandler->processBackgroundTasks();
974
} catch (Exception $e) {
975
    error_log('Teampass Background Tasks Error: ' . $e->getMessage());
976
}
977