Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.89% covered (success)
91.89%
34 / 37
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cleanup
91.89% covered (success)
91.89%
34 / 37
50.00% covered (danger)
50.00%
2 / 4
23.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 execute
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
13
 purgeFolder
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Command\SignatureElements;
10
11use OCA\Libresign\Service\EmailSignerElementsService;
12use OCA\Libresign\Service\FolderService;
13use OCP\AppFramework\Utility\ITimeFactory;
14use OCP\Files\File;
15use OCP\Files\Folder;
16use Symfony\Component\Console\Command\Command;
17use Symfony\Component\Console\Input\InputInterface;
18use Symfony\Component\Console\Input\InputOption;
19use Symfony\Component\Console\Output\OutputInterface;
20
21/** Explicit retention for guest images, including keys orphaned by secret rotation. */
22class Cleanup extends Command {
23    public function __construct(
24        private FolderService $folderService,
25        private ITimeFactory $timeFactory,
26    ) {
27        parent::__construct();
28    }
29
30    #[\Override]
31    protected function configure(): void {
32        $this->setName('libresign:signature-elements:cleanup')
33            ->setDescription('Purge old saved email signatures and anonymous session images, even when their policy is disabled')
34            ->addOption('days', null, InputOption::VALUE_REQUIRED, 'Delete folders whose images have not changed in this many days (required)')
35            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'List eligible folders without deleting them');
36    }
37
38    #[\Override]
39    protected function execute(InputInterface $input, OutputInterface $output): int {
40        $days = filter_var($input->getOption('days'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
41        if ($days === false || $days === null) {
42            $output->writeln('<error>Specify a positive number of days with --days.</error>');
43            return self::INVALID;
44        }
45        $cutoff = $this->timeFactory->getDateTime()->modify('-' . $days . ' days')->getTimestamp();
46        $dryRun = (bool)$input->getOption('dry-run');
47        $count = 0;
48        foreach ($this->folderService->getUnauthenticatedFolder()->getDirectoryListing() as $folder) {
49            if (!$folder instanceof Folder) {
50                continue;
51            }
52            if ($folder->getName() === EmailSignerElementsService::STORAGE_FOLDER) {
53                foreach ($folder->getDirectoryListing() as $saved) {
54                    if ($saved instanceof Folder && preg_match('/^[a-f0-9]{64}$/D', $saved->getName())
55                        && $this->purgeFolder($saved, '/^(signature|initial)\.png$/D', $cutoff, $dryRun, $output)) {
56                        $count++;
57                    }
58                }
59            } elseif (preg_match('/^[a-zA-Z0-9,-]{16,128}$/D', $folder->getName())
60                && $this->purgeFolder($folder, '/^(signature|initial)_[0-9]+\.png$/D', $cutoff, $dryRun, $output)) {
61                $count++;
62            }
63        }
64        $output->writeln(($dryRun ? 'Would delete ' : 'Deleted ') . $count . ' signature folders.');
65        return self::SUCCESS;
66    }
67
68    private function purgeFolder(Folder $folder, string $pattern, int $cutoff, bool $dryRun, OutputInterface $output): bool {
69        if ($folder->getMTime() >= $cutoff) {
70            return false;
71        }
72        $files = $folder->getDirectoryListing();
73        if ($files === []) {
74            return false;
75        }
76        foreach ($files as $file) {
77            // The unauthenticated container can also contain documents. Only purge
78            // folders holding exclusively signature images with the expected names.
79            if (!$file instanceof File || !preg_match($pattern, $file->getName()) || $file->getMTime() >= $cutoff) {
80                return false;
81            }
82        }
83        $output->writeln($folder->getName());
84        if (!$dryRun) {
85            $folder->delete();
86        }
87        return true;
88    }
89}