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

BackgroundTasksHandler::countRunningTasks()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 0
dl 0
loc 4
rs 10
c 0
b 0
f 0
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-2025 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
43
    public function __construct(array $settings) {
44
        $this->settings = $settings;
45
        $this->logger = new TaskLogger($settings, LOG_TASKS_FILE);
46
        $this->maxParallelTasks = $settings['max_parallel_tasks'] ?? 2;
47
        $this->maxExecutionTime = $settings['task_maximum_run_time'] ?? 600;
48
        $this->batchSize = $settings['task_batch_size'] ?? 50;
49
        // Tasks history retention (seconds)
50
        // Prefer new setting `tasks_history_delay` (stored in seconds in DB),
51
        // fallback to legacy `history_duration` (days) if present, otherwise 15 days.
52
        $historyDelay = 0;
53
54
        if (isset($settings['tasks_history_delay']) === true) {
55
            $historyDelay = (int) $settings['tasks_history_delay'];
56
        } elseif (isset($settings['history_duration']) === true) {
57
            $historyDelay = (int) $settings['history_duration'] * 86400;
58
        }
59
60
        // Safety: if for any reason it is stored in days, convert to seconds.
61
        if ($historyDelay > 0 && $historyDelay < 86400) {
62
            $historyDelay = $historyDelay * 86400;
63
        }
64
65
$this->maxTimeBeforeRemoval = $historyDelay > 0 ? $historyDelay : (15 * 86400);
66
67
    }
68
69
    /**
70
     * Main function to process background tasks
71
     */
72
    public function processBackgroundTasks() {
73
        // Prevent multiple concurrent executions
74
        if (!$this->acquireProcessLock()) {
75
            if (LOG_TASKS=== true) $this->logger->log('Process already running', 'INFO');
76
            return false;
77
        }
78
79
        try {
80
            $this->cleanupStaleTasks();
81
            $this->handleScheduledDatabaseBackup();   // <--- NEW
82
            $this->processTaskBatches();
83
            $this->performMaintenanceTasks();
84
        } catch (Exception $e) {
85
            if (LOG_TASKS=== true) $this->logger->log('Task processing error: ' . $e->getMessage(), 'ERROR');
86
        } finally {
87
            $this->releaseProcessLock();
88
        }
89
    }
90
    /**
91
     * Scheduler: enqueue a database_backup task when due.
92
     */
93
    private function handleScheduledDatabaseBackup(): void
94
    {
95
        $enabled = (int)$this->getSettingValue('bck_scheduled_enabled', '0');
96
        if ($enabled !== 1) {
97
            return;
98
        }
99
100
        $now = time();
101
102
        // Output dir
103
        $outputDir = (string)$this->getSettingValue('bck_scheduled_output_dir', '');
104
        if ($outputDir === '') {
105
            $baseFilesDir = (string)($this->settings['path_to_files_folder'] ?? (__DIR__ . '/../files'));
106
            $outputDir = rtrim($baseFilesDir, '/') . '/backups';
107
            $this->upsertSettingValue('bck_scheduled_output_dir', $outputDir);
108
        }
109
110
        // next_run_at
111
        $nextRunAt = (int)$this->getSettingValue('bck_scheduled_next_run_at', '0');
112
        if ($nextRunAt <= 0) {
113
            $nextRunAt = $this->computeNextBackupRunAt($now);
114
            $this->upsertSettingValue('bck_scheduled_next_run_at', (string)$nextRunAt);
115
            if (LOG_TASKS === true) $this->logger->log('backup scheduler initialized next_run_at=' . $nextRunAt, 'INFO');
116
            return;
117
        }
118
119
        if ($now < $nextRunAt) {
120
            return;
121
        }
122
123
        // Avoid duplicates: if a database_backup task is already pending or running, skip.
124
        $pending = (int)DB::queryFirstField(
125
            'SELECT COUNT(*)
126
            FROM ' . prefixTable('background_tasks') . '
127
            WHERE process_type = %s
128
            AND is_in_progress IN (0,1)
129
            AND (finished_at IS NULL OR finished_at = "" OR finished_at = 0)',
130
            'database_backup'
131
        );
132
133
        if ($pending > 0) {
134
            if (LOG_TASKS === true) $this->logger->log('backup scheduler: a database_backup task is already pending/running', 'INFO');
135
            return;
136
        }
137
138
        // Enqueue task
139
        DB::insert(
140
            prefixTable('background_tasks'),
141
            [
142
                'created_at' => (string)$now,
143
                'process_type' => 'database_backup',
144
                'arguments' => json_encode(
145
                    [
146
                        'output_dir' => $outputDir,
147
                        'source' => 'scheduler',
148
                    ],
149
                    JSON_UNESCAPED_SLASHES
150
                ),
151
                'is_in_progress' => 0,
152
                'status' => 'new',
153
            ]
154
        );
155
156
    $this->upsertSettingValue('bck_scheduled_last_run_at', (string)$now);
157
    $this->upsertSettingValue('bck_scheduled_last_status', 'queued');
158
    $this->upsertSettingValue('bck_scheduled_last_message', 'Task enqueued by scheduler');
159
160
        // Compute next run
161
        $newNext = $this->computeNextBackupRunAt($now + 60);
162
        $this->upsertSettingValue('bck_scheduled_next_run_at', (string)$newNext);
163
164
        if (LOG_TASKS === true) $this->logger->log('backup scheduler: enqueued database_backup, next_run_at=' . $newNext, 'INFO');
165
    }
166
167
    /**
168
     * Compute next run timestamp based on settings:
169
     * - bck_scheduled_frequency: daily|weekly|monthly (default daily)
170
     * - bck_scheduled_time: HH:MM (default 02:00)
171
     * - bck_scheduled_dow: 1..7 (ISO, Mon=1) for weekly (default 1)
172
     * - bck_scheduled_dom: 1..31 for monthly (default 1)
173
     */
174
    
175
    private function getTeampassTimezoneName(): string
176
{
177
    // TeamPass stores timezone in teampass_misc: type='admin', intitule='timezone'
178
    $tz = DB::queryFirstField(
179
        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE type = %s AND intitule = %s LIMIT 1',
180
        'admin',
181
        'timezone'
182
    );
183
184
    return (is_string($tz) && $tz !== '') ? $tz : 'UTC';
185
}
186
    
187
    private function computeNextBackupRunAt(int $fromTs): int
188
    {
189
        // On se base sur la timezone PHP du serveur (simple et robuste)
190
        $tzName = $this->getTeampassTimezoneName();
191
        try {
192
            $tz = new DateTimeZone($tzName);
193
        } catch (Throwable $e) {
194
            $tz = new DateTimeZone('UTC');
195
        }
196
197
        $freq = (string)$this->getSettingValue('bck_scheduled_frequency', 'daily');
198
        $timeStr = (string)$this->getSettingValue('bck_scheduled_time', '02:00');
199
200
        if (!preg_match('/^\d{2}:\d{2}$/', $timeStr)) {
201
            $timeStr = '02:00';
202
        }
203
        [$hh, $mm] = array_map('intval', explode(':', $timeStr));
204
205
        $now = (new DateTimeImmutable('@' . $fromTs))->setTimezone($tz);
206
        $candidate = $now->setTime($hh, $mm, 0);
207
208
        if ($freq === 'weekly') {
209
            $targetDow = (int)$this->getSettingValue('bck_scheduled_dow', '1'); // ISO 1..7
210
            if ($targetDow < 1 || $targetDow > 7) $targetDow = 1;
211
212
            $currentDow = (int)$candidate->format('N');
213
            $delta = ($targetDow - $currentDow + 7) % 7;
214
            if ($delta === 0 && $candidate <= $now) {
215
                $delta = 7;
216
            }
217
            $candidate = $candidate->modify('+' . $delta . ' days');
218
219
        } elseif ($freq === 'monthly') {
220
            $dom = (int)$this->getSettingValue('bck_scheduled_dom', '1');
221
            if ($dom < 1) $dom = 1;
222
            if ($dom > 31) $dom = 31;
223
224
            $year = (int)$now->format('Y');
225
            $month = (int)$now->format('m');
226
            $daysInMonth = (int)$now->format('t');
227
            $day = min($dom, $daysInMonth);
228
229
            $candidate = $now->setDate($year, $month, $day)->setTime($hh, $mm, 0);
230
            if ($candidate <= $now) {
231
                $nextMonth = $now->modify('first day of next month');
232
                $year2 = (int)$nextMonth->format('Y');
233
                $month2 = (int)$nextMonth->format('m');
234
                $daysInMonth2 = (int)$nextMonth->format('t');
235
                $day2 = min($dom, $daysInMonth2);
236
237
                $candidate = $nextMonth->setDate($year2, $month2, $day2)->setTime($hh, $mm, 0);
238
            }
239
240
        } else {
241
            // daily
242
            if ($candidate <= $now) {
243
                $candidate = $candidate->modify('+1 day');
244
            }
245
        }
246
247
        return $candidate->getTimestamp();
248
    }
249
250
    /**
251
     * Read a setting from teampass_misc (type='settings', intitule=key).
252
     */
253
    private function getSettingValue(string $key, string $default = ''): string
254
    {
255
        $table = prefixTable('misc');
256
257
        // Schéma TeamPass classique: misc(type, intitule, valeur)
258
        $val = DB::queryFirstField(
259
            'SELECT valeur FROM ' . $table . ' WHERE type = %s AND intitule = %s LIMIT 1',
260
            'settings',
261
            $key
262
        );
263
264
        if ($val === null || $val === false || $val === '') {
265
            return $default;
266
        }
267
268
        return (string)$val;
269
    }
270
271
    /**
272
     * Upsert a setting into teampass_misc (type='settings', intitule=key).
273
     */
274
    private function upsertSettingValue(string $key, string $value): void
275
    {
276
        $table = prefixTable('misc');
277
278
        $exists = (int)DB::queryFirstField(
279
            'SELECT COUNT(*) FROM ' . $table . ' WHERE type = %s AND intitule = %s',
280
            'settings',
281
            $key
282
        );
283
284
        if ($exists > 0) {
285
            DB::update($table, ['valeur' => $value], 'type = %s AND intitule = %s', 'settings', $key);
286
        } else {
287
            DB::insert($table, ['type' => 'settings', 'intitule' => $key, 'valeur' => $value]);
288
        }
289
290
        // keep in memory too
291
        $this->settings[$key] = $value;
292
    }
293
294
    /**
295
     * Acquire a lock to prevent multiple instances of this script from running simultaneously.
296
     * @return bool
297
     */
298
    private function acquireProcessLock(): bool {
299
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
300
        
301
        $fp = fopen($lockFile, 'w');
302
        
303
        if (!flock($fp, LOCK_EX | LOCK_NB)) {
304
            return false;
305
        }
306
        
307
        fwrite($fp, (string)getmypid());
308
        return true;
309
    }
310
311
    /**
312
     * Release the lock file.
313
     */
314
    private function releaseProcessLock() {
315
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
316
        if (file_exists($lockFile)) {
317
            unlink($lockFile);
318
        }
319
    }
320
321
    /**
322
     * Cleanup stale tasks that have been running for too long or are marked as failed.
323
     */
324
    private function cleanupStaleTasks() {
325
        // Mark tasks as failed if they've been running too long
326
        DB::query(
327
            'UPDATE ' . prefixTable('background_tasks') . ' 
328
            SET is_in_progress = -1, 
329
                finished_at = %i, 
330
                status = "failed",
331
                error_message = "Task exceeded maximum execution time of '.$this->maxExecutionTime.' seconds"
332
            WHERE is_in_progress = 1 
333
            AND updated_at < %i',
334
            time(),
335
            time() - $this->maxExecutionTime
336
        );
337
338
        // Remove very old failed tasks
339
        DB::query(
340
            'DELETE t, st FROM ' . prefixTable('background_tasks') . ' t
341
            INNER JOIN ' . prefixTable('background_subtasks') . ' st ON (t.increment_id = st.task_id)
342
            WHERE t.finished_at > 0
343
              AND t.finished_at < %i
344
              AND t.status = %s',
345
            time() - $this->maxTimeBeforeRemoval,
346
            'failed'
347
        );
348
    }
349
350
    /**
351
     * Process batches of tasks.
352
     * This method fetches tasks from the database and processes them in parallel.
353
     */
354
    private function processTaskBatches() {
355
        $runningTasks = $this->countRunningTasks();
356
        
357
        // Check if the maximum number of parallel tasks is reached
358
        if ($runningTasks >= $this->maxParallelTasks) {
359
            if (LOG_TASKS=== true) $this->logger->log('Wait ... '.$runningTasks.' out of '.$this->maxParallelTasks.' are already running ', 'INFO');
360
            return;
361
        }
362
363
        $availableSlotsCount = $this->maxParallelTasks - $runningTasks;
364
365
        // Fetch next batch of tasks
366
        $tasks = DB::query(
367
            'SELECT increment_id, process_type, arguments 
368
            FROM ' . prefixTable('background_tasks') . '
369
            WHERE is_in_progress = 0 
370
            AND (finished_at IS NULL OR finished_at = "")
371
            ORDER BY increment_id ASC
372
            LIMIT %i',
373
            min($this->batchSize, $availableSlotsCount)
374
        );
375
376
        foreach ($tasks as $task) {
377
            if (LOG_TASKS=== true) $this->logger->log('Launching '.$task['increment_id'], 'INFO');
378
            $this->processIndividualTask($task);
379
        }
380
    }
381
382
    /**
383
     * Process an individual task.
384
     * This method updates the task status in the database and starts a new process for the task.
385
     * @param array $task The task to process.
386
     */
387
    private function processIndividualTask(array $task) {
388
        if (LOG_TASKS=== true)  $this->logger->log('Starting task: ' . print_r($task, true), 'INFO');
389
390
        // Store progress in the database        
391
        DB::update(
392
            prefixTable('background_tasks'),
393
            [
394
                'is_in_progress' => 1,
395
                'started_at' => time(),
396
                'updated_at' => time(),
397
                'status' => 'in_progress'
398
            ],
399
            'increment_id = %i',
400
            $task['increment_id']
401
        );
402
403
        // Prepare process
404
        $cmd = sprintf(
405
            '%s %s %d %s %s',
406
            escapeshellarg(PHP_BINARY),
407
            escapeshellarg(__DIR__ . '/background_tasks___worker.php'),
408
            (int) $task['increment_id'],
409
            escapeshellarg((string) $task['process_type']),
410
            escapeshellarg((string) $task['arguments'])
411
        );
412
413
        $process = Process::fromShellCommandline($cmd);
414
415
        // Launch process
416
        try {
417
            $process->setTimeout($this->maxExecutionTime);
418
            $process->mustRun();
419
420
        } catch (Throwable $e) {
421
            // If Symfony Process cannot spawn, fallback to exec()
422
            $msg = $e->getMessage();
423
            $last = error_get_last();
424
            if (is_array($last)) {
425
                $msg .= ' | last_error=' . json_encode($last);
426
            }
427
428
            if (strpos($msg, 'Unable to launch a new process') !== false) {
429
                $out = [];
430
                $rc = 0;
431
432
                // fallback run (blocking)
433
                exec($cmd . ' 2>&1', $out, $rc);
434
435
                if ($rc === 0) {
436
                    // Worker ran successfully and updated the DB itself
437
                    if (LOG_TASKS === true) $this->logger->log('Fallback exec succeeded for task ' . $task['increment_id'], 'INFO');
438
                    return;
439
                }
440
441
                $msg .= ' | fallback_exit=' . $rc . ' | fallback_out=' . implode("\n", array_slice($out, -30));
442
            }
443
444
            if (LOG_TASKS=== true) $this->logger->log('Error launching task: ' . $msg, 'ERROR');
445
446
            DB::update(
447
                prefixTable('background_tasks'),
448
                [
449
                    'is_in_progress' => -1,
450
                    'finished_at' => time(),
451
                    'status' => 'failed',
452
                    'error_message' => $msg
453
                ],
454
                'increment_id = %i',
455
                $task['increment_id']
456
            );
457
        }
458
    }
459
460
    /**
461
     * Count the number of currently running tasks.
462
     * @return int The number of running tasks.
463
     */
464
    private function countRunningTasks(): int {
465
        return DB::queryFirstField(
466
            'SELECT COUNT(*) 
467
            FROM ' . prefixTable('background_tasks') . ' 
468
            WHERE is_in_progress = 1'
469
        );
470
    }
471
472
    /**
473
     * Perform maintenance tasks.
474
     * This method cleans up old items, expired tokens, and finished tasks.
475
     */
476
    private function performMaintenanceTasks() {
477
        $this->cleanMultipleItemsEdition();
478
        $this->handleItemTokensExpiration();
479
        $this->cleanOldFinishedTasks();
480
    }
481
482
    /**
483
     * Clean up multiple items edition.
484
     * This method removes duplicate entries in the items_edition table.
485
     */
486
    private function cleanMultipleItemsEdition() {
487
        DB::query(
488
            'DELETE i1 FROM ' . prefixTable('items_edition') . ' i1
489
            JOIN (
490
                SELECT user_id, item_id, MIN(timestamp) AS oldest_timestamp
491
                FROM ' . prefixTable('items_edition') . '
492
                GROUP BY user_id, item_id
493
            ) i2 ON i1.user_id = i2.user_id AND i1.item_id = i2.item_id
494
            WHERE i1.timestamp > i2.oldest_timestamp'
495
        );
496
    }
497
498
    /**
499
     * Handle item tokens expiration.
500
     * This method removes expired tokens from the items_edition table.
501
     */
502
    private function handleItemTokensExpiration() {
503
        DB::query(
504
            'DELETE FROM ' . prefixTable('items_edition') . '
505
            WHERE timestamp < %i',
506
            time() - ($this->settings['delay_item_edition'] * 60 ?: EDITION_LOCK_PERIOD)
507
        );
508
    }
509
510
    /**
511
     * Clean up old finished tasks.
512
     * This method removes tasks that have been completed for too long.
513
     */
514
    private function cleanOldFinishedTasks() {
515
        // Timestamp cutoff for removal
516
        $cutoffTimestamp = time() - $this->maxTimeBeforeRemoval;
517
    
518
        // 1. Get all finished tasks older than the cutoff timestamp
519
        //    and that are not in progress
520
        $tasks = DB::query(
521
            'SELECT increment_id FROM ' . prefixTable('background_tasks') . '
522
            WHERE status = %s AND is_in_progress = %i AND finished_at < %i',
523
            'completed',
524
            -1,
525
            $cutoffTimestamp
526
        );
527
        
528
        if (empty($tasks)) {
529
            return;
530
        }
531
    
532
        $taskIds = array_column($tasks, 'increment_id');
533
    
534
        // 2. Delete all subtasks related to these tasks
535
        DB::query(
536
            'DELETE FROM ' . prefixTable('background_subtasks') . '
537
            WHERE task_id IN %ls',
538
            $taskIds
539
        );
540
    
541
        // 3. Delete the tasks themselves
542
        DB::query(
543
            'DELETE FROM ' . prefixTable('background_tasks') . '
544
            WHERE increment_id IN %ls',
545
            $taskIds
546
        );
547
    
548
        if (LOG_TASKS=== true) $this->logger->log('Old finished tasks cleaned: ' . count($taskIds), 'INFO');
549
    }
550
}
551
552
553
554
// Main execution
555
try {
556
    $configManager = new ConfigManager();
557
    $settings = $configManager->getAllSettings();
558
    
559
    $tasksHandler = new BackgroundTasksHandler($settings);
560
    $tasksHandler->processBackgroundTasks();
561
} catch (Exception $e) {
562
    error_log('Teampass Background Tasks Error: ' . $e->getMessage());
563
}
564