From 84109224b35b4950202c1cd6a2011de6ffa9017c Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 27 Oct 2025 13:25:37 +0100 Subject: [PATCH 1/4] feat: Create shares list report + send it by email + diff Co-Authored-By: Florent Poinsaut Signed-off-by: Carl Schwan --- README.md | 33 +++- appinfo/info.xml | 1 + composer.json | 3 +- composer.lock | 46 ++++- lib/Command/AbstractCommand.php | 78 ++++++++ lib/Command/ListShares.php | 45 +---- lib/Command/SendShares.php | 140 +++++++++++++++ lib/Service/ReportSender.php | 310 ++++++++++++++++++++++++++++++++ 8 files changed, 609 insertions(+), 47 deletions(-) create mode 100644 lib/Command/AbstractCommand.php create mode 100644 lib/Command/SendShares.php create mode 100644 lib/Service/ReportSender.php diff --git a/README.md b/README.md index a0539781..afd6c060 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ This app allows generating reports of shares on the system. ## Usage -### Command +### Commands + +#### List ```sh ./occ sharing:list [-u|--user [USER]] [-p|--path [PATH]] [-t|--token [TOKEN]] [-f|--filter [FILTER]] [-o|--output FORMAT] @@ -13,7 +15,7 @@ This app allows generating reports of shares on the system. Without options, the command yields the unfiltered list of all shares.\ With options, the list is narrowed down using the filters set. -### Options +##### Options * `-u [USER]` or `--user [USER]`\ List only shares of the given user. @@ -27,6 +29,33 @@ With options, the list is narrowed down using the filters set. * `-o FORMAT` or `--output FORMAT`\ Set the output format (json or csv, default is json). +#### Send + +```sh +./occ sharing:send [-u|--user USER] [-p|--path PATH] [-t|--token TOKEN] [-f|--filter FILTER] [-o|--output FORMAT] +``` + +Without options, the command yields the unfiltered list of all shares.\ +With options, the list is narrowed down using the filters set. + +##### Options + +* `-r` or `--recipients`\ + Recipients users of generated reports. +* `-x` or `--target-path`\ + Generated reports will be stored on this path. +* `-d` or `--diff`\ + Create a differential report in json format from the last available report. +* `-u [USER]` or `--user [USER]`\ + List only shares of the given user. +* `-p [PATH]` or `--path [PATH]`\ + List only shares within the given path. +* `-t [TOKEN]` or `--token [TOKEN]`\ + List only shares that use a token that (at least partly) matches the argument. +* `-f [FILTER]` or `--filter [FILTER]`\ + List only shares where the TYPE matches the argument.\ + Possible values for the filter argument: {owner, initiator, recipient} + ## Examples To better illustrate how the app work see the examples below: diff --git a/appinfo/info.xml b/appinfo/info.xml index a4e03e7c..94981ccf 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -17,5 +17,6 @@ OCA\ShareListing\Command\ListShares + OCA\ShareListing\Command\SendShares diff --git a/composer.json b/composer.json index f90e82b9..803873ad 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "require": { "nikic/iter": "^2.4", "symfony/serializer": "^5.4", - "bamarni/composer-bin-plugin": "^1.8" + "bamarni/composer-bin-plugin": "^1.8", + "swaggest/json-diff": "^3.12" } } diff --git a/composer.lock b/composer.lock index 9c5748f2..895e016c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ce14d7d43a48200082e893362e994083", + "content-hash": "50e9ce56ce91be8126d5a8cff07bb89c", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -115,6 +115,50 @@ }, "time": "2024-03-19T20:45:05+00:00" }, + { + "name": "swaggest/json-diff", + "version": "v3.12.1", + "source": { + "type": "git", + "url": "https://github.com/swaggest/json-diff.git", + "reference": "7ebc4eab95bcc73916433964c266588d09b35052" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swaggest/json-diff/zipball/7ebc4eab95bcc73916433964c266588d09b35052", + "reference": "7ebc4eab95bcc73916433964c266588d09b35052", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.1" + }, + "require-dev": { + "phperf/phpunit": "4.8.37" + }, + "type": "library", + "autoload": { + "psr-4": { + "Swaggest\\JsonDiff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Viacheslav Poturaev", + "email": "vearutop@gmail.com" + } + ], + "description": "JSON diff/rearrange/patch/pointer library for PHP", + "support": { + "issues": "https://github.com/swaggest/json-diff/issues", + "source": "https://github.com/swaggest/json-diff/tree/v3.12.1" + }, + "time": "2025-03-10T08:22:10+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v3.6.0", diff --git a/lib/Command/AbstractCommand.php b/lib/Command/AbstractCommand.php new file mode 100644 index 00000000..7a3bf562 --- /dev/null +++ b/lib/Command/AbstractCommand.php @@ -0,0 +1,78 @@ + + * + * @author Florent Poinsaut + * @author Roeland Jago Douma + * @author John Molakvoæ + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\ShareListing\Command; + +use OCA\ShareListing\Service\SharesList; +use OC\Core\Command\Base; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +abstract class AbstractCommand extends Base { + /** @var SharesList */ + protected $sharesList; + + public function __construct(SharesList $sharesList) { + parent::__construct(); + + $this->sharesList = $sharesList; + } + + public function configure() { + $this->addOption( + 'user', + 'u', + InputOption::VALUE_OPTIONAL, + 'Will list shares of the given user' + ) + ->addOption( + 'path', + 'p', + InputOption::VALUE_OPTIONAL, + 'Will only consider the given path' + )->addOption( + 'token', + 't', + InputOption::VALUE_OPTIONAL, + 'Will only consider the given token' + )->addOption( + 'filter', + 'f', + InputOption::VALUE_OPTIONAL, + 'Filter shares, possible values: owner, initiator, recipient, token, has-expiration, no-expiration' + ); + } + + protected function getOptions(InputInterface $input): array { + $user = $input->getOption('user'); + $path = $input->getOption('path'); + $token = $input->getOption('token'); + $filter = $this->sharesList->filterStringToInt($input->getOption('filter')); + + return [$user, $path, $token, $filter]; + } +} diff --git a/lib/Command/ListShares.php b/lib/Command/ListShares.php index 6ff37039..79767b44 100644 --- a/lib/Command/ListShares.php +++ b/lib/Command/ListShares.php @@ -27,53 +27,15 @@ namespace OCA\ShareListing\Command; -use OC\Core\Command\Base; -use OCA\ShareListing\Service\SharesList; -use OCP\Files\IRootFolder; -use OCP\IUserManager; -use OCP\Share\IManager as ShareManager; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function iter\toArray; -class ListShares extends Base { - - public function __construct( - private ShareManager $shareManager, - private IUserManager $userManager, - private IRootFolder $rootFolder, - private SharesList $sharesList, - ) { - parent::__construct(); - - } - +class ListShares extends AbstractCommand { public function configure() { $this->setName('sharing:list') ->setDescription('List who has access to shares by owner') - ->addOption( - 'user', - 'u', - InputOption::VALUE_OPTIONAL, - 'Will list shares of the given user' - ) - ->addOption( - 'path', - 'p', - InputOption::VALUE_OPTIONAL, - 'Will only consider the given path' - )->addOption( - 'token', - 't', - InputOption::VALUE_OPTIONAL, - 'Will only consider the given token' - )->addOption( - 'filter', - 'f', - InputOption::VALUE_OPTIONAL, - 'Filter shares, possible values: owner, initiator, recipient, token, has-expiration, no-expiration' - ) ->addOption( 'output', 'o', @@ -84,10 +46,7 @@ public function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $user = $input->getOption('user'); - $path = $input->getOption('path'); - $token = $input->getOption('token'); - $filter = $this->sharesList->filterStringToInt($input->getOption('filter')); + [$user, $path, $token, $filter] = $this->getOptions($input); $outputOpt = $input->getOption('output'); $shares = toArray($this->sharesList->getFormattedShares($user, $filter, $path, $token)); diff --git a/lib/Command/SendShares.php b/lib/Command/SendShares.php new file mode 100644 index 00000000..5127f4fe --- /dev/null +++ b/lib/Command/SendShares.php @@ -0,0 +1,140 @@ + + * + * @author Florent Poinsaut + * @author Roeland Jago Douma + * @author John Molakvoæ + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\ShareListing\Command; + +use OCA\ShareListing\Service\ReportSender; +use OCA\ShareListing\Service\SharesList; +use OCP\IUserManager; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class SendShares extends AbstractCommand { + /** @var IUserManager */ + private $userManager; + + /** @var ReportSender */ + private $reportSender; + + public function __construct( + IUserManager $userManager, + ReportSender $reportSender, + SharesList $sharesList + ) { + parent::__construct($sharesList); + + $this->userManager = $userManager; + $this->reportSender = $reportSender; + } + + public function configure() { + parent::configure(); + + $this->setName('sharing:send') + ->setDescription('Send list who has access to shares by owner') + ->addOption( + 'diff', + 'd', + InputOption::VALUE_NONE, + 'Create a differential report in json format from the last available report' + ) + ->addOption( + 'recipients', + 'r', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Recipients users of generated reports' + ) + ->addOption( + 'target-path', + 'x', + InputOption::VALUE_REQUIRED, + 'Generated reports will be stored on this path' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->checkAllRequiredOptionsAreNotEmpty($input); + + [$user, $path, $token, $filter] = $this->getOptions($input); + $diff = $input->getOption('diff'); + $recipients = $input->getOption('recipients'); + $targetPath = $input->getOption('target-path'); + + $dateTime = new \DateTimeImmutable(); + + foreach ($recipients as $recipient) { + $this->reportSender->createReport( + $recipient, + $targetPath, + $dateTime, + $user, + $filter, + $path, + $token + ); + + if ($diff) { + $this->reportSender->diff($recipient, $targetPath); + } + + $this->reportSender->sendReport($recipient, $dateTime); + } + return 0; + } + + protected function checkAllRequiredOptionsAreNotEmpty(InputInterface $input) + { + $errors = []; + + if (!$input->getOption('target-path')) { + $errors[] = 'The required option --target-path is not set or is empty.'; + } + + $recipients = $this->getDefinition()->getOption('recipients'); + + /** @var InputOption $recipient */ + foreach ([$recipients] as $recipient) { + $name = $recipient->getName(); + $values = $input->getOption($name); + + if ($values === null || $values === '' || ($recipient->isArray() && empty($values))) { + $errors[] = sprintf('The required option --%s is not set or is empty.', $name); + } + + foreach ($values as $value) { + if (!$this->userManager->userExists($value)) { + $errors[] = sprintf('The recipient user %s does not exist.', $value); + } + } + } + + if (count($errors)) { + throw new \InvalidArgumentException(implode("\n\n", $errors)); + } + } +} diff --git a/lib/Service/ReportSender.php b/lib/Service/ReportSender.php new file mode 100644 index 00000000..3c47ac00 --- /dev/null +++ b/lib/Service/ReportSender.php @@ -0,0 +1,310 @@ + + * + * @author Florent Poinsaut + * @author Robin Appelman + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\ShareListing\Service; + +use iter; +use OC\Files\Search\SearchBinaryOperator; +use OC\Files\Search\SearchComparison; +use OC\Files\Search\SearchOrder; +use OC\Files\Search\SearchQuery; +use OCA\ShareListing\Service\SharesList; +use OCP\Defaults; +use OCP\Files\FileInfo; +use OCP\Files\Folder; +use OCP\Files\InvalidDirectoryException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchComparison; +use OCP\Files\Search\ISearchOrder; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Mail\IMailer; +use OCP\Util; +use Psr\Log\LoggerInterface; +use Swaggest\JsonDiff\JsonDiff; + +class ReportSender +{ + protected const REPORT_NAME = ' - Shares report.'; + + /** @var string */ + private $appName; + /** @var Iconfig */ + private $config; + /** @var ?array */ + protected $diffReport = null; + + private $mailer; + private $userManager; + private $defaults; + private $l10nFactory; + private $logger; + + /** @var array */ + protected $reports = []; + /** @var SharesList */ + private $sharesList; + /** @var IRootFolder */ + private $root; + /** @var IURLGenerator */ + protected $url; + + public function __construct( + string $appName, + IConfig $config, + IMailer $mailer, + IUserManager $userManager, + Defaults $defaults, + IFactory $l10nFactory, + LoggerInterface $logger, + SharesList $sharesList, + IRootFolder $root, + IURLGenerator $url + ) { + $this->appName = $appName; + $this->config = $config; + $this->mailer = $mailer; + $this->userManager = $userManager; + $this->defaults = $defaults; + $this->l10nFactory = $l10nFactory; + $this->logger = $logger; + $this->sharesList = $sharesList; + $this->root = $root; + $this->url = $url; + } + + public function createReport( + string $recipient, + string $targetPath, + \DateTimeImmutable $dateTime, + ?string $userId = '', + int $filter = SharesList::FILTER_NONE, + string $path = null, + string $token = null + ) { + $userFolder = $this->root->getUserFolder($recipient); + + if ($userFolder->nodeExists($targetPath)) { + /** @var Folder $folder */ + $folder = $userFolder->get($targetPath); + if ($folder->getType() !== FileInfo::TYPE_FOLDER) { + $this->logger->warning( + 'Target path ' . $targetPath . ' is not a folder', + ['app' => $this->appName] + ); + } + } else { + $folder = $userFolder->newFolder($targetPath); + } + + $formats = ['json', 'csv']; + $formatedDateTime = $dateTime->format('YmdHi'); + foreach ($formats as $key => $format) { + $fileName = $formatedDateTime . self::REPORT_NAME . $format; + if (!array_key_exists($fileName, $this->reports)) { + if ($key === array_key_first($formats)) { + $shares = iter\toArray($this->sharesList->getFormattedShares($userId, $filter, $path, $token)); + } + $reportFile = $folder->newFile($fileName); + $data = $this->sharesList->getSerializedShares($shares, $format); + $reportFile->putContent($data); + $this->reports[$reportFile->getName()] = [ + 'url' => $this->url->linkToRouteAbsolute( + 'files.View.showFile', + ['fileid' => $reportFile->getId()] + ), + 'data' => $data + ]; + } + } + } + + public function sendReport(string $recipient, \DateTimeImmutable $dateTime) + { + $defaultLanguage = $this->config->getSystemValue('default_language', 'en'); + $userLanguages = $this->config->getUserValue($recipient, 'core', 'lang'); + $language = (!empty($userLanguages)) ? $userLanguages : $defaultLanguage; + + $l10n = $this->l10nFactory->get('shareslist', $language); + + $template = $this->mailer->createEMailTemplate('shareslist.Notification', [ + 'date-time' => $dateTime, + ]); + + $formatedDateTime = $dateTime->format(\DateTimeInterface::COOKIE); + $template->setSubject($l10n->t('Shares reports generated on %s', $formatedDateTime)); + $template->addHeader(); + $template->addBodyText('You can find the list of shares reports generated on ' . $formatedDateTime . ':'); + + foreach ($this->reports as $name => $value) { + $template->addBodyListItem( + '' . $name . '', + '', + '', + $name . ': ' . $value['url'] + ); + } + if ($this->diffReport !== null) { + $template->addBodyText('You can also find the differential between this report and the previous one:'); + $template->addBodyListItem( + '' . $this->diffReport['fileName'] . '', + '', + '', + $this->diffReport['fileName'] . ': ' . $this->diffReport['url'] + ); + } + + $template->addBodyText('Permissions mapping:'); + $template->addBodyListItem('1 = read'); + $template->addBodyListItem('2 = update'); + $template->addBodyListItem('4 = create'); + $template->addBodyListItem('8 = delete'); + $template->addBodyListItem('16 = share'); + $template->addBodyListItem('31 = all (default: 31, for public shares: 1)'); + + $template->addFooter('', $language); + + $message = $this->mailer->createMessage(); + $message->setTo([$this->getEmailAdressFromUserId($recipient)]); + $message->useTemplate($template); + $message->setFrom([Util::getDefaultEmailAddress('no-reply') => $this->defaults->getName()]); + + try { + $this->mailer->send($message); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + return; + } + } + + protected function getEmailAdressFromUserId(string $userId): ?string + { + $user = $this->userManager->get($userId); + if ($user === null) { + $this->logger->warning( + 'ShareList error, the user "' . $userId . '" does not exist.', + ['app' => $this->appName] + ); + return null; + } + + $email = $user->getEMailAddress(); + if ($email === null || $email === '') { + $this->logger->warning( + 'ShareList error, the user "' . $userId . '" does not have an email set up.', + ['app' => $this->appName] + ); + return null; + } + + return $email; + } + + public function diff( + string $userId, + string $dir + ) { + $userFolder = $this->root->getUserFolder($userId); + + if ($userFolder->nodeExists($dir)) { + /** @var Folder $folder */ + $folder = $userFolder->get($dir); + if ($folder->getType() !== FileInfo::TYPE_FOLDER) { + throw new InvalidDirectoryException('Invalid directory, "' . $dir . '" not a folder'); + } + } else { + throw new InvalidDirectoryException('Invalid directory, "' . $dir . '" does not exist'); + } + + $search = $userFolder->search( + new SearchQuery( + new SearchBinaryOperator( + ISearchBinaryOperator::OPERATOR_OR, + [ + new SearchComparison( + ISearchComparison::COMPARE_LIKE, + 'name', + '%' . self::REPORT_NAME . 'json' + ) + ] + ), + 1, + 1, + [new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')] + ) + ); + + if (empty($search)) { + throw new NotFoundException('No previous report found on this folder.'); + } + + $previousFile = $search[0]; + $previousFilename = $previousFile->getName(); + $previousDateTime = substr($previousFilename, 0, 12); + $previousContent = json_decode($previousFile->getContent()); + $previousContentWithId = []; + foreach ($previousContent as $value) { + $previousContentWithId[$value->id] = $value; + } + + $newFilename = array_keys($this->reports)[0]; + $newDateTime = substr($newFilename, 0, 12); + $newContent = json_decode(array_values($this->reports)[0]['data']); + $newContentWithId = []; + foreach ($newContent as $value) { + $newContentWithId[$value->id] = $value; + } + + $jsonDiff = new JsonDiff( + $previousContentWithId, + $newContentWithId, + JsonDiff::REARRANGE_ARRAYS + JsonDiff::COLLECT_MODIFIED_DIFF + ); + + $reportFilename = $previousDateTime . ' - ' . $newDateTime . ' - Shares report diff.json'; + $reportFile = $folder->newFile($reportFilename); + $res = [ + 'added' => $jsonDiff->getAdded(), + 'removed' => $jsonDiff->getRemoved(), + 'modified' => $jsonDiff->getModifiedDiff() + ]; + + $reportFile->putContent(json_encode($res, JSON_PRETTY_PRINT)); + + $this->diffReport = [ + 'url' => $this->url->linkToRouteAbsolute( + 'files.View.showFile', + ['fileid' => $reportFile->getId()] + ), + 'fileName' => $reportFilename + ]; + } +} From e39f7c770d67eed6becca833fd6835cb91ac63d8 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 27 Oct 2025 13:45:37 +0100 Subject: [PATCH 2/4] fix(psalm): Add stubs for private nextcloud APIs Signed-off-by: Carl Schwan --- lib/Command/AbstractCommand.php | 55 ++- lib/Command/ListShares.php | 5 +- lib/Command/SendShares.php | 52 +-- lib/Service/ReportSender.php | 22 +- psalm.xml | 15 + tests/phpunit.xml | 1 + tests/stub.phpstub | 58 --- tests/stubs/oc_core_command_base.php | 76 ++++ .../oc_files_search_searchbinaryoperator.php | 49 +++ .../oc_files_search_searchcomparison.php | 58 +++ tests/stubs/oc_files_search_searchorder.php | 31 ++ tests/stubs/oc_files_search_searchquery.php | 60 +++ ...on_completion_completionawareinterface.php | 26 ++ ...fony_component_console_command_command.php | 403 ++++++++++++++++++ ...symfony_component_console_helper_table.php | 191 +++++++++ ..._component_console_input_inputargument.php | 97 +++++ ...component_console_input_inputinterface.php | 150 +++++++ ...ny_component_console_input_inputoption.php | 143 +++++++ ...mponent_console_output_outputinterface.php | 110 +++++ ..._console_question_confirmationquestion.php | 27 ++ ...ny_component_console_question_question.php | 184 ++++++++ 21 files changed, 1684 insertions(+), 129 deletions(-) create mode 100644 tests/stubs/oc_core_command_base.php create mode 100644 tests/stubs/oc_files_search_searchbinaryoperator.php create mode 100644 tests/stubs/oc_files_search_searchcomparison.php create mode 100644 tests/stubs/oc_files_search_searchorder.php create mode 100644 tests/stubs/oc_files_search_searchquery.php create mode 100644 tests/stubs/stecman_component_symfony_console_bashcompletion_completion_completionawareinterface.php create mode 100644 tests/stubs/symfony_component_console_command_command.php create mode 100644 tests/stubs/symfony_component_console_helper_table.php create mode 100644 tests/stubs/symfony_component_console_input_inputargument.php create mode 100644 tests/stubs/symfony_component_console_input_inputinterface.php create mode 100644 tests/stubs/symfony_component_console_input_inputoption.php create mode 100644 tests/stubs/symfony_component_console_output_outputinterface.php create mode 100644 tests/stubs/symfony_component_console_question_confirmationquestion.php create mode 100644 tests/stubs/symfony_component_console_question_question.php diff --git a/lib/Command/AbstractCommand.php b/lib/Command/AbstractCommand.php index 7a3bf562..ed8eb555 100644 --- a/lib/Command/AbstractCommand.php +++ b/lib/Command/AbstractCommand.php @@ -1,4 +1,5 @@ sharesList = $sharesList; } - public function configure() { + public function configure(): void { $this->addOption( - 'user', - 'u', - InputOption::VALUE_OPTIONAL, - 'Will list shares of the given user' - ) - ->addOption( - 'path', - 'p', - InputOption::VALUE_OPTIONAL, - 'Will only consider the given path' - )->addOption( - 'token', - 't', - InputOption::VALUE_OPTIONAL, - 'Will only consider the given token' - )->addOption( - 'filter', - 'f', - InputOption::VALUE_OPTIONAL, - 'Filter shares, possible values: owner, initiator, recipient, token, has-expiration, no-expiration' - ); + 'user', + 'u', + InputOption::VALUE_OPTIONAL, + 'Will list shares of the given user' + )->addOption( + 'path', + 'p', + InputOption::VALUE_OPTIONAL, + 'Will only consider the given path' + )->addOption( + 'token', + 't', + InputOption::VALUE_OPTIONAL, + 'Will only consider the given token' + )->addOption( + 'filter', + 'f', + InputOption::VALUE_OPTIONAL, + 'Filter shares, possible values: owner, initiator, recipient, token, has-expiration, no-expiration' + ); } protected function getOptions(InputInterface $input): array { diff --git a/lib/Command/ListShares.php b/lib/Command/ListShares.php index 79767b44..268f55d0 100644 --- a/lib/Command/ListShares.php +++ b/lib/Command/ListShares.php @@ -32,8 +32,11 @@ use Symfony\Component\Console\Output\OutputInterface; use function iter\toArray; +/** + * @psalm-api + */ class ListShares extends AbstractCommand { - public function configure() { + public function configure(): void { $this->setName('sharing:list') ->setDescription('List who has access to shares by owner') ->addOption( diff --git a/lib/Command/SendShares.php b/lib/Command/SendShares.php index 5127f4fe..58611e1a 100644 --- a/lib/Command/SendShares.php +++ b/lib/Command/SendShares.php @@ -1,4 +1,5 @@ userManager = $userManager; - $this->reportSender = $reportSender; } - public function configure() { + public function configure(): void { parent::configure(); $this->setName('sharing:send') @@ -107,34 +102,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - protected function checkAllRequiredOptionsAreNotEmpty(InputInterface $input) - { - $errors = []; + protected function checkAllRequiredOptionsAreNotEmpty(InputInterface $input): void { + $errors = []; if (!$input->getOption('target-path')) { $errors[] = 'The required option --target-path is not set or is empty.'; } - $recipients = $this->getDefinition()->getOption('recipients'); + $recipients = $this->getDefinition()->getOption('recipients'); - /** @var InputOption $recipient */ - foreach ([$recipients] as $recipient) { - $name = $recipient->getName(); - $values = $input->getOption($name); + /** @var InputOption $option */ + foreach ([$recipients] as $option) { + $name = $option->getName(); + $values = $input->getOption($name); - if ($values === null || $values === '' || ($recipient->isArray() && empty($values))) { - $errors[] = sprintf('The required option --%s is not set or is empty.', $name); - } + if ($values === null || $values === '' || ($option->isArray() && empty($values))) { + $errors[] = sprintf('The required option --%s is not set or is empty.', $name); + } foreach ($values as $value) { if (!$this->userManager->userExists($value)) { $errors[] = sprintf('The recipient user %s does not exist.', $value); } } - } + } - if (count($errors)) { - throw new \InvalidArgumentException(implode("\n\n", $errors)); - } - } + if (count($errors)) { + throw new \InvalidArgumentException(implode("\n\n", $errors)); + } + } } diff --git a/lib/Service/ReportSender.php b/lib/Service/ReportSender.php index 3c47ac00..0945b2a4 100644 --- a/lib/Service/ReportSender.php +++ b/lib/Service/ReportSender.php @@ -7,7 +7,7 @@ * * @author Florent Poinsaut * @author Robin Appelman - * + * * @license GNU AGPL version 3 or any later version * * This program is free software: you can redistribute it and/or modify @@ -32,8 +32,8 @@ use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchOrder; use OC\Files\Search\SearchQuery; -use OCA\ShareListing\Service\SharesList; use OCP\Defaults; +use OCP\Files\File; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\InvalidDirectoryException; @@ -51,8 +51,7 @@ use Psr\Log\LoggerInterface; use Swaggest\JsonDiff\JsonDiff; -class ReportSender -{ +class ReportSender { protected const REPORT_NAME = ' - Shares report.'; /** @var string */ @@ -87,7 +86,7 @@ public function __construct( LoggerInterface $logger, SharesList $sharesList, IRootFolder $root, - IURLGenerator $url + IURLGenerator $url, ) { $this->appName = $appName; $this->config = $config; @@ -107,8 +106,8 @@ public function createReport( \DateTimeImmutable $dateTime, ?string $userId = '', int $filter = SharesList::FILTER_NONE, - string $path = null, - string $token = null + ?string $path = null, + ?string $token = null, ) { $userFolder = $this->root->getUserFolder($recipient); @@ -147,8 +146,7 @@ public function createReport( } } - public function sendReport(string $recipient, \DateTimeImmutable $dateTime) - { + public function sendReport(string $recipient, \DateTimeImmutable $dateTime) { $defaultLanguage = $this->config->getSystemValue('default_language', 'en'); $userLanguages = $this->config->getUserValue($recipient, 'core', 'lang'); $language = (!empty($userLanguages)) ? $userLanguages : $defaultLanguage; @@ -205,8 +203,7 @@ public function sendReport(string $recipient, \DateTimeImmutable $dateTime) } } - protected function getEmailAdressFromUserId(string $userId): ?string - { + protected function getEmailAdressFromUserId(string $userId): ?string { $user = $this->userManager->get($userId); if ($user === null) { $this->logger->warning( @@ -230,7 +227,7 @@ protected function getEmailAdressFromUserId(string $userId): ?string public function diff( string $userId, - string $dir + string $dir, ) { $userFolder = $this->root->getUserFolder($userId); @@ -266,6 +263,7 @@ public function diff( throw new NotFoundException('No previous report found on this folder.'); } + /** @var File $previousFile */ $previousFile = $search[0]; $previousFilename = $previousFile->getName(); $previousDateTime = substr($previousFilename, 0, 12); diff --git a/psalm.xml b/psalm.xml index 5371cd94..5de7c179 100644 --- a/psalm.xml +++ b/psalm.xml @@ -12,6 +12,21 @@ > + + + + + + + + + + + + + + + diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 0027316a..c7eafb80 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -19,6 +19,7 @@ . + ./stubs/ diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 9e81ee21..461e12ea 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -68,64 +68,6 @@ namespace OC\Cache { } } -namespace OC\Core\Command { - use Symfony\Component\Console\Input\InputInterface; - use Symfony\Component\Console\Output\OutputInterface; - class Base { - public const OUTPUT_FORMAT_PLAIN = 'plain'; - public const OUTPUT_FORMAT_JSON = 'json'; - public const OUTPUT_FORMAT_JSON_PRETTY = 'json_pretty'; - - public function __construct() {} - protected function configure() {} - protected function execute(InputInterface $input, OutputInterface $output): int {} - public function setName(string $name) {} - public function getHelper(string $name) {} - protected function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, $items, $prefix = ' - ') { - } - } -} - namespace OC\Files\ObjectStore { class NoopScanner {} } - -namespace Symfony\Component\Console\Helper { - use Symfony\Component\Console\Output\OutputInterface; - class Table { - public function __construct(OutputInterface $text) {} - public function setHeaders(array $header) {} - public function setRows(array $rows) {} - public function render() {} - } -} - -namespace Symfony\Component\Console\Input { - class InputInterface { - public function getOption(string $key) {} - public function getArgument(string $key) {} - } - class InputArgument { - const REQUIRED = 0; - const OPTIONAL = 1; - const IS_ARRAY = 1; - } - class InputOption { - const VALUE_NONE = 1; - const VALUE_REQUIRED = 1; - const VALUE_OPTIONAL = 1; - } -} - -namespace Symfony\Component\Console\Question { - class ConfirmationQuestion { - public function __construct(string $text, bool $default) {} - } -} - -namespace Symfony\Component\Console\Output { - class OutputInterface { - public const VERBOSITY_VERBOSE = 1; - public function writeln(string $text, int $flat = 0) {} - } -} diff --git a/tests/stubs/oc_core_command_base.php b/tests/stubs/oc_core_command_base.php new file mode 100644 index 00000000..26cfa3f8 --- /dev/null +++ b/tests/stubs/oc_core_command_base.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Command; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\ExceptionInterface; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Helper\HelperInterface; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Base class for all commands. + * + * @author Fabien Potencier + */ +class Command { + // see https://tldp.org/LDP/abs/html/exitcodes.html + public const SUCCESS = 0; + public const FAILURE = 1; + public const INVALID = 2; + + /** + * @var string|null The default command name + * + * @deprecated since Symfony 6.1, use the AsCommand attribute instead + */ + protected static $defaultName; + + /** + * @var string|null The default command description + * + * @deprecated since Symfony 6.1, use the AsCommand attribute instead + */ + protected static $defaultDescription; + + public static function getDefaultName(): ?string { + } + + public static function getDefaultDescription(): ?string { + } + + /** + * @param string|null $name The name of the command; passing null means it must be set in configure() + * + * @throws LogicException When the command name is empty + */ + public function __construct(?string $name = null) { + } + + /** + * Ignores validation errors. + * + * This is mainly useful for the help command. + * + * @return void + */ + public function ignoreValidationErrors() { + } + + /** + * @return void + */ + public function setApplication(?Application $application = null) { + } + + /** + * @return void + */ + public function setHelperSet(HelperSet $helperSet) { + } + + /** + * Gets the helper set. + */ + public function getHelperSet(): ?HelperSet { + } + + /** + * Gets the application instance for this command. + */ + public function getApplication(): ?Application { + } + + /** + * Checks whether the command is enabled or not in the current environment. + * + * Override this to check for x or y and return false if the command cannot + * run properly under the current conditions. + * + * @return bool + */ + public function isEnabled() { + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() { + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @return int 0 if everything went fine, or an exit code + * + * @throws LogicException When this abstract method is not implemented + * + * @see setCode() + */ + protected function execute(InputInterface $input, OutputInterface $output) { + } + + /** + * Interacts with the user. + * + * This method is executed before the InputDefinition is validated. + * This means that this is the only place where the command can + * interactively ask for values of missing required arguments. + * + * @return void + */ + protected function interact(InputInterface $input, OutputInterface $output) { + } + + /** + * Initializes the command after the input has been bound and before the input + * is validated. + * + * This is mainly useful when a lot of commands extends one main command + * where some things need to be initialized based on the input arguments and options. + * + * @see InputInterface::bind() + * @see InputInterface::validate() + * + * @return void + */ + protected function initialize(InputInterface $input, OutputInterface $output) { + } + + /** + * Runs the command. + * + * The code to execute is either defined directly with the + * setCode() method or by overriding the execute() method + * in a sub-class. + * + * @return int The command exit code + * + * @throws ExceptionInterface When input binding fails. Bypass this by calling {@link ignoreValidationErrors()}. + * + * @see setCode() + * @see execute() + */ + public function run(InputInterface $input, OutputInterface $output): int { + } + + /** + * Adds suggestions to $suggestions for the current completion input (e.g. option or argument). + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { + } + + /** + * Sets the code to execute when running this command. + * + * If this method is used, it overrides the code defined + * in the execute() method. + * + * @param callable $code A callable(InputInterface $input, OutputInterface $output) + * + * @return $this + * + * @throws InvalidArgumentException + * + * @see execute() + */ + public function setCode(callable $code): static { + } + + /** + * Merges the application definition with the command definition. + * + * This method is not part of public API and should not be used directly. + * + * @param bool $mergeArgs Whether to merge or not the Application definition arguments to Command definition arguments + * + * @internal + */ + public function mergeApplicationDefinition(bool $mergeArgs = true): void { + } + + /** + * Sets an array of argument and option instances. + * + * @return $this + */ + public function setDefinition(array|InputDefinition $definition): static { + } + + /** + * Gets the InputDefinition attached to this Command. + */ + public function getDefinition(): InputDefinition { + } + + /** + * Gets the InputDefinition to be used to create representations of this Command. + * + * Can be overridden to provide the original command representation when it would otherwise + * be changed by merging with the application InputDefinition. + * + * This method is not part of public API and should not be used directly. + */ + public function getNativeDefinition(): InputDefinition { + } + + /** + * Adds an argument. + * + * @param $mode The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL + * @param $default The default value (for InputArgument::OPTIONAL mode only) + * @param array|\Closure(CompletionInput,CompletionSuggestions):list $suggestedValues The values used for input completion + * + * @return $this + * + * @throws InvalidArgumentException When argument mode is not valid + */ + public function addArgument(string $name, ?int $mode = null, string $description = '', mixed $default = null): static { + } + + /** + * Adds an option. + * + * @param $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param $mode The option mode: One of the InputOption::VALUE_* constants + * @param $default The default value (must be null for InputOption::VALUE_NONE) + * @param array|\Closure(CompletionInput,CompletionSuggestions):list $suggestedValues The values used for input completion + * + * @return $this + * + * @throws InvalidArgumentException If option mode is invalid or incompatible + */ + public function addOption(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null): static { + } + + /** + * Sets the name of the command. + * + * This method can set both the namespace and the name if + * you separate them by a colon (:) + * + * $command->setName('foo:bar'); + * + * @return $this + * + * @throws InvalidArgumentException When the name is invalid + */ + public function setName(string $name): static { + } + + /** + * Sets the process title of the command. + * + * This feature should be used only when creating a long process command, + * like a daemon. + * + * @return $this + */ + public function setProcessTitle(string $title): static { + } + + /** + * Returns the command name. + */ + public function getName(): ?string { + } + + /** + * @param bool $hidden Whether or not the command should be hidden from the list of commands + * + * @return $this + */ + public function setHidden(bool $hidden = true): static { + } + + /** + * @return bool whether the command should be publicly shown or not + */ + public function isHidden(): bool { + } + + /** + * Sets the description for the command. + * + * @return $this + */ + public function setDescription(string $description): static { + } + + /** + * Returns the description for the command. + */ + public function getDescription(): string { + } + + /** + * Sets the help for the command. + * + * @return $this + */ + public function setHelp(string $help): static { + } + + /** + * Returns the help for the command. + */ + public function getHelp(): string { + } + + /** + * Returns the processed help for the command replacing the %command.name% and + * %command.full_name% patterns with the real values dynamically. + */ + public function getProcessedHelp(): string { + } + + /** + * Sets the aliases for the command. + * + * @param string[] $aliases An array of aliases for the command + * + * @return $this + * + * @throws InvalidArgumentException When an alias is invalid + */ + public function setAliases(iterable $aliases): static { + } + + /** + * Returns the aliases for the command. + */ + public function getAliases(): array { + } + + /** + * Returns the synopsis for the command. + * + * @param bool $short Whether to show the short version of the synopsis (with options folded) or not + */ + public function getSynopsis(bool $short = false): string { + } + + /** + * Add a command usage example, it'll be prefixed with the command name. + * + * @return $this + */ + public function addUsage(string $usage): static { + } + + /** + * Returns alternative usages of the command. + */ + public function getUsages(): array { + } + + /** + * Gets a helper instance by name. + * + * @return HelperInterface + * + * @throws LogicException if no HelperSet is defined + * @throws InvalidArgumentException if the helper is not defined + */ + public function getHelper(string $name): mixed { + } +} diff --git a/tests/stubs/symfony_component_console_helper_table.php b/tests/stubs/symfony_component_console_helper_table.php new file mode 100644 index 00000000..2c8d6e83 --- /dev/null +++ b/tests/stubs/symfony_component_console_helper_table.php @@ -0,0 +1,191 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Helper; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Provides helpers to display a table. + * + * @author Fabien Potencier + * @author Саша Стаменковић + * @author Abdellatif Ait boudad + * @author Max Grigorian + * @author Dany Maillard + */ +class Table { + private const SEPARATOR_TOP = 0; + private const SEPARATOR_TOP_BOTTOM = 1; + private const SEPARATOR_MID = 2; + private const SEPARATOR_BOTTOM = 3; + private const BORDER_OUTSIDE = 0; + private const BORDER_INSIDE = 1; + private const DISPLAY_ORIENTATION_DEFAULT = 'default'; + private const DISPLAY_ORIENTATION_HORIZONTAL = 'horizontal'; + private const DISPLAY_ORIENTATION_VERTICAL = 'vertical'; + + public function __construct(OutputInterface $output) { + } + + /** + * Sets a style definition. + * + * @return void + */ + public static function setStyleDefinition(string $name, TableStyle $style) { + } + + /** + * Gets a style definition by name. + */ + public static function getStyleDefinition(string $name): TableStyle { + } + + /** + * Sets table style. + * + * @return $this + */ + public function setStyle(TableStyle|string $name): static { + } + + /** + * Gets the current table style. + */ + public function getStyle(): TableStyle { + } + + /** + * Sets table column style. + * + * @param TableStyle|string $name The style name or a TableStyle instance + * + * @return $this + */ + public function setColumnStyle(int $columnIndex, TableStyle|string $name): static { + } + + /** + * Gets the current style for a column. + * + * If style was not set, it returns the global table style. + */ + public function getColumnStyle(int $columnIndex): TableStyle { + } + + /** + * Sets the minimum width of a column. + * + * @return $this + */ + public function setColumnWidth(int $columnIndex, int $width): static { + } + + /** + * Sets the minimum width of all columns. + * + * @return $this + */ + public function setColumnWidths(array $widths): static { + } + + /** + * Sets the maximum width of a column. + * + * Any cell within this column which contents exceeds the specified width will be wrapped into multiple lines, while + * formatted strings are preserved. + * + * @return $this + */ + public function setColumnMaxWidth(int $columnIndex, int $width): static { + } + + /** + * @return $this + */ + public function setHeaders(array $headers): static { + } + + /** + * @return $this + */ + public function setRows(array $rows) { + } + + /** + * @return $this + */ + public function addRows(array $rows): static { + } + + /** + * @return $this + */ + public function addRow(TableSeparator|array $row): static { + } + + /** + * Adds a row to the table, and re-renders the table. + * + * @return $this + */ + public function appendRow(TableSeparator|array $row): static { + } + + /** + * @return $this + */ + public function setRow(int|string $column, array $row): static { + } + + /** + * @return $this + */ + public function setHeaderTitle(?string $title): static { + } + + /** + * @return $this + */ + public function setFooterTitle(?string $title): static { + } + + /** + * @return $this + */ + public function setHorizontal(bool $horizontal = true): static { + } + + /** + * @return $this + */ + public function setVertical(bool $vertical = true): static { + } + + /** + * Renders table to output. + * + * Example: + * + * +---------------+-----------------------+------------------+ + * | ISBN | Title | Author | + * +---------------+-----------------------+------------------+ + * | 99921-58-10-7 | Divine Comedy | Dante Alighieri | + * | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | + * | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | + * +---------------+-----------------------+------------------+ + * + * @return void + */ + public function render() { + } +} diff --git a/tests/stubs/symfony_component_console_input_inputargument.php b/tests/stubs/symfony_component_console_input_inputargument.php new file mode 100644 index 00000000..5f08d55e --- /dev/null +++ b/tests/stubs/symfony_component_console_input_inputargument.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Input; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException; + +/** + * Represents a command line argument. + * + * @author Fabien Potencier + */ +class InputArgument { + public const REQUIRED = 1; + public const OPTIONAL = 2; + public const IS_ARRAY = 4; + + /** + * @param string $name The argument name + * @param int|null $mode The argument mode: a bit mask of self::REQUIRED, self::OPTIONAL and self::IS_ARRAY + * @param string $description A description text + * @param string|bool|int|float|array|null $default The default value (for self::OPTIONAL mode only) + * @param array|\Closure(CompletionInput,CompletionSuggestions):list $suggestedValues The values used for input completion + * + * @throws InvalidArgumentException When argument mode is not valid + */ + public function __construct(string $name, ?int $mode = null, string $description = '', string|bool|int|float|array|null $default = null, \Closure|array $suggestedValues = []) { + } + + /** + * Returns the argument name. + */ + public function getName(): string { + } + + /** + * Returns true if the argument is required. + * + * @return bool true if parameter mode is self::REQUIRED, false otherwise + */ + public function isRequired(): bool { + } + + /** + * Returns true if the argument can take multiple values. + * + * @return bool true if mode is self::IS_ARRAY, false otherwise + */ + public function isArray(): bool { + } + + /** + * Sets the default value. + * + * @return void + * + * @throws LogicException When incorrect default value is given + */ + public function setDefault(string|bool|int|float|array|null $default = null) { + } + + /** + * Returns the default value. + */ + public function getDefault(): string|bool|int|float|array|null { + } + + public function hasCompletion(): bool { + } + + /** + * Adds suggestions to $suggestions for the current completion input. + * + * @see Command::complete() + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { + } + + /** + * Returns the description text. + */ + public function getDescription(): string { + } +} diff --git a/tests/stubs/symfony_component_console_input_inputinterface.php b/tests/stubs/symfony_component_console_input_inputinterface.php new file mode 100644 index 00000000..486c25f3 --- /dev/null +++ b/tests/stubs/symfony_component_console_input_inputinterface.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Input; + +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\RuntimeException; + +/** + * InputInterface is the interface implemented by all input classes. + * + * @author Fabien Potencier + * + * @method string __toString() Returns a stringified representation of the args passed to the command. + * InputArguments MUST be escaped as well as the InputOption values passed to the command. + */ +interface InputInterface { + /** + * Returns the first argument from the raw parameters (not parsed). + */ + public function getFirstArgument(): ?string; + + /** + * Returns true if the raw parameters (not parsed) contain a value. + * + * This method is to be used to introspect the input parameters + * before they have been validated. It must be used carefully. + * Does not necessarily return the correct result for short options + * when multiple flags are combined in the same option. + * + * @param string|array $values The values to look for in the raw parameters (can be an array) + * @param bool $onlyParams Only check real parameters, skip those following an end of options (--) signal + */ + public function hasParameterOption(string|array $values, bool $onlyParams = false): bool; + + /** + * Returns the value of a raw option (not parsed). + * + * This method is to be used to introspect the input parameters + * before they have been validated. It must be used carefully. + * Does not necessarily return the correct result for short options + * when multiple flags are combined in the same option. + * + * @param string|array $values The value(s) to look for in the raw parameters (can be an array) + * @param string|bool|int|float|array|null $default The default value to return if no result is found + * @param bool $onlyParams Only check real parameters, skip those following an end of options (--) signal + * + * @return mixed + */ + public function getParameterOption(string|array $values, string|bool|int|float|array|null $default = false, bool $onlyParams = false); + + /** + * Binds the current Input instance with the given arguments and options. + * + * @return void + * + * @throws RuntimeException + */ + public function bind(InputDefinition $definition); + + /** + * Validates the input. + * + * @return void + * + * @throws RuntimeException When not enough arguments are given + */ + public function validate(); + + /** + * Returns all the given arguments merged with the default values. + * + * @return array + */ + public function getArguments(): array; + + /** + * Returns the argument value for a given argument name. + * + * @return mixed + * + * @throws InvalidArgumentException When argument given doesn't exist + */ + public function getArgument(string $name); + + /** + * Sets an argument value by name. + * + * @return void + * + * @throws InvalidArgumentException When argument given doesn't exist + */ + public function setArgument(string $name, mixed $value); + + /** + * Returns true if an InputArgument object exists by name or position. + */ + public function hasArgument(string $name): bool; + + /** + * Returns all the given options merged with the default values. + * + * @return array + */ + public function getOptions(): array; + + /** + * Returns the option value for a given option name. + * + * @return mixed + * + * @throws InvalidArgumentException When option given doesn't exist + */ + public function getOption(string $name); + + + /** + * Sets an option value by name. + * + * @return void + * + * @throws InvalidArgumentException When option given doesn't exist + */ + public function setOption(string $name, mixed $value); + + /** + * Returns true if an InputOption object exists by name. + */ + public function hasOption(string $name): bool; + + /** + * Is this input means interactive? + */ + public function isInteractive(): bool; + + /** + * Sets the input interactivity. + * + * @return void + */ + public function setInteractive(bool $interactive); +} diff --git a/tests/stubs/symfony_component_console_input_inputoption.php b/tests/stubs/symfony_component_console_input_inputoption.php new file mode 100644 index 00000000..a51e22d9 --- /dev/null +++ b/tests/stubs/symfony_component_console_input_inputoption.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Input; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidArgumentException; + +/** + * Represents a command line option. + * + * @author Fabien Potencier + */ +class InputOption { + /** + * Do not accept input for the option (e.g. --yell). This is the default behavior of options. + */ + public const VALUE_NONE = 1; + + /** + * A value must be passed when the option is used (e.g. --iterations=5 or -i5). + */ + public const VALUE_REQUIRED = 2; + + /** + * The option may or may not have a value (e.g. --yell or --yell=loud). + */ + public const VALUE_OPTIONAL = 4; + + /** + * The option accepts multiple values (e.g. --dir=/foo --dir=/bar). + */ + public const VALUE_IS_ARRAY = 8; + + /** + * The option may have either positive or negative value (e.g. --ansi or --no-ansi). + */ + public const VALUE_NEGATABLE = 16; + + /** + * @param string|array|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param int|null $mode The option mode: One of the VALUE_* constants + * @param string|bool|int|float|array|null $default The default value (must be null for self::VALUE_NONE) + * @param array|\Closure(CompletionInput,CompletionSuggestions):list $suggestedValues The values used for input completion + * + * @throws InvalidArgumentException If option mode is invalid or incompatible + */ + public function __construct(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', string|bool|int|float|array|null $default = null, array|\Closure $suggestedValues = []) { + } + + /** + * Returns the option shortcut. + */ + public function getShortcut(): ?string { + } + + /** + * Returns the option name. + */ + public function getName(): string { + } + + /** + * Returns true if the option accepts a value. + * + * @return bool true if value mode is not self::VALUE_NONE, false otherwise + */ + public function acceptValue(): bool { + } + + /** + * Returns true if the option requires a value. + * + * @return bool true if value mode is self::VALUE_REQUIRED, false otherwise + */ + public function isValueRequired(): bool { + } + + /** + * Returns true if the option takes an optional value. + * + * @return bool true if value mode is self::VALUE_OPTIONAL, false otherwise + */ + public function isValueOptional(): bool { + } + + /** + * Returns true if the option can take multiple values. + * + * @return bool true if mode is self::VALUE_IS_ARRAY, false otherwise + */ + public function isArray(): bool { + } + + public function isNegatable(): bool { + } + + /** + * @return void + */ + public function setDefault(string|bool|int|float|array|null $default = null) { + } + + /** + * Returns the default value. + */ + public function getDefault(): string|bool|int|float|array|null { + } + + /** + * Returns the description text. + */ + public function getDescription(): string { + } + + public function hasCompletion(): bool { + } + + /** + * Adds suggestions to $suggestions for the current completion input. + * + * @see Command::complete() + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { + } + + /** + * Checks whether the given option equals this one. + */ + public function equals(self $option): bool { + } +} diff --git a/tests/stubs/symfony_component_console_output_outputinterface.php b/tests/stubs/symfony_component_console_output_outputinterface.php new file mode 100644 index 00000000..d7a2adfe --- /dev/null +++ b/tests/stubs/symfony_component_console_output_outputinterface.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Output; + +use Symfony\Component\Console\Formatter\OutputFormatterInterface; + +/** + * OutputInterface is the interface implemented by all Output classes. + * + * @author Fabien Potencier + */ +interface OutputInterface { + public const VERBOSITY_QUIET = 16; + public const VERBOSITY_NORMAL = 32; + public const VERBOSITY_VERBOSE = 64; + public const VERBOSITY_VERY_VERBOSE = 128; + public const VERBOSITY_DEBUG = 256; + + public const OUTPUT_NORMAL = 1; + public const OUTPUT_RAW = 2; + public const OUTPUT_PLAIN = 4; + + /** + * Writes a message to the output. + * + * @param bool $newline Whether to add a newline + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL + * + * @return void + */ + public function write(string|iterable $messages, bool $newline = false, int $options = 0); + + /** + * Writes a message to the output and adds a newline at the end. + * + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL + * + * @return void + */ + public function writeln(string|iterable $messages, int $options = 0); + + /** + * Sets the verbosity of the output. + * + * @param self::VERBOSITY_* $level + * + * @return void + */ + public function setVerbosity(int $level); + + /** + * Gets the current verbosity of the output. + * + * @return self::VERBOSITY_* + */ + public function getVerbosity(): int; + + /** + * Returns whether verbosity is quiet (-q). + */ + public function isQuiet(): bool; + + /** + * Returns whether verbosity is verbose (-v). + */ + public function isVerbose(): bool; + + /** + * Returns whether verbosity is very verbose (-vv). + */ + public function isVeryVerbose(): bool; + + /** + * Returns whether verbosity is debug (-vvv). + */ + public function isDebug(): bool; + + /** + * Sets the decorated flag. + * + * @return void + */ + public function setDecorated(bool $decorated); + + /** + * Gets the decorated flag. + */ + public function isDecorated(): bool; + + /** + * @return void + */ + public function setFormatter(OutputFormatterInterface $formatter); + + /** + * Returns current output formatter instance. + */ + public function getFormatter(): OutputFormatterInterface; +} diff --git a/tests/stubs/symfony_component_console_question_confirmationquestion.php b/tests/stubs/symfony_component_console_question_confirmationquestion.php new file mode 100644 index 00000000..1a8ad296 --- /dev/null +++ b/tests/stubs/symfony_component_console_question_confirmationquestion.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Question; + +/** + * Represents a yes/no question. + * + * @author Fabien Potencier + */ +class ConfirmationQuestion extends Question { + /** + * @param string $question The question to ask to the user + * @param bool $default The default answer to return, true or false + * @param string $trueAnswerRegex A regex to match the "yes" answer + */ + public function __construct(string $question, bool $default = true, string $trueAnswerRegex = '/^y/i') { + } +} diff --git a/tests/stubs/symfony_component_console_question_question.php b/tests/stubs/symfony_component_console_question_question.php new file mode 100644 index 00000000..9e6f5fcf --- /dev/null +++ b/tests/stubs/symfony_component_console_question_question.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Question; + +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException; + +/** + * Represents a Question. + * + * @author Fabien Potencier + */ +class Question { + /** + * @param string $question The question to ask to the user + * @param string|bool|int|float|null $default The default answer to return if the user enters nothing + */ + public function __construct(string $question, string|bool|int|float|null $default = null) { + } + + /** + * Returns the question. + */ + public function getQuestion(): string { + } + + /** + * Returns the default answer. + */ + public function getDefault(): string|bool|int|float|null { + } + + /** + * Returns whether the user response accepts newline characters. + */ + public function isMultiline(): bool { + } + + /** + * Sets whether the user response should accept newline characters. + * + * @return $this + */ + public function setMultiline(bool $multiline): static { + } + + /** + * Returns whether the user response must be hidden. + */ + public function isHidden(): bool { + } + + /** + * Sets whether the user response must be hidden or not. + * + * @return $this + * + * @throws LogicException In case the autocompleter is also used + */ + public function setHidden(bool $hidden): static { + } + + /** + * In case the response cannot be hidden, whether to fallback on non-hidden question or not. + */ + public function isHiddenFallback(): bool { + } + + /** + * Sets whether to fallback on non-hidden question if the response cannot be hidden. + * + * @return $this + */ + public function setHiddenFallback(bool $fallback): static { + } + + /** + * Gets values for the autocompleter. + */ + public function getAutocompleterValues(): ?iterable { + } + + /** + * Sets values for the autocompleter. + * + * @return $this + * + * @throws LogicException + */ + public function setAutocompleterValues(?iterable $values): static { + } + + /** + * Gets the callback function used for the autocompleter. + */ + public function getAutocompleterCallback(): ?callable { + } + + /** + * Sets the callback function used for the autocompleter. + * + * The callback is passed the user input as argument and should return an iterable of corresponding suggestions. + * + * @return $this + */ + public function setAutocompleterCallback(?callable $callback = null): static { + } + + /** + * Sets a validator for the question. + * + * @return $this + */ + public function setValidator(?callable $validator = null): static { + } + + /** + * Gets the validator for the question. + */ + public function getValidator(): ?callable { + } + + /** + * Sets the maximum number of attempts. + * + * Null means an unlimited number of attempts. + * + * @return $this + * + * @throws InvalidArgumentException in case the number of attempts is invalid + */ + public function setMaxAttempts(?int $attempts): static { + } + + /** + * Gets the maximum number of attempts. + * + * Null means an unlimited number of attempts. + */ + public function getMaxAttempts(): ?int { + } + + /** + * Sets a normalizer for the response. + * + * The normalizer can be a callable (a string), a closure or a class implementing __invoke. + * + * @return $this + */ + public function setNormalizer(callable $normalizer): static { + } + + /** + * Gets the normalizer for the response. + * + * The normalizer can ba a callable (a string), a closure or a class implementing __invoke. + */ + public function getNormalizer(): ?callable { + } + + /** + * @return bool + */ + protected function isAssoc(array $array) { + } + + public function isTrimmable(): bool { + } + + /** + * @return $this + */ + public function setTrimmable(bool $trimmable): static { + } +} From 36be8ec922091d584380511e3f8b5a5026569192 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 27 Oct 2025 13:48:41 +0100 Subject: [PATCH 3/4] refactor: Modernized ReportSender Signed-off-by: Carl Schwan --- lib/Command/AbstractCommand.php | 29 ++--------- lib/Command/SendShares.php | 29 ++--------- lib/Service/ReportSender.php | 87 +++++++-------------------------- 3 files changed, 29 insertions(+), 116 deletions(-) diff --git a/lib/Command/AbstractCommand.php b/lib/Command/AbstractCommand.php index ed8eb555..d8ef66e2 100644 --- a/lib/Command/AbstractCommand.php +++ b/lib/Command/AbstractCommand.php @@ -1,30 +1,11 @@ - * - * @author Florent Poinsaut - * @author Roeland Jago Douma - * @author John Molakvoæ - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ + +// SPDX-FileCopyrightText: 2018 Roeland Jago Douma +// SPDX-FileCopyrightText: 2022 Solution Libre SAS +// SPDX-FileContributor: Florent Poinsaut +// SPDX-License-Identifier: AGPL-3.0-or-later namespace OCA\ShareListing\Command; diff --git a/lib/Command/SendShares.php b/lib/Command/SendShares.php index 58611e1a..2650e20a 100644 --- a/lib/Command/SendShares.php +++ b/lib/Command/SendShares.php @@ -1,30 +1,11 @@ - * - * @author Florent Poinsaut - * @author Roeland Jago Douma - * @author John Molakvoæ - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ + +// SPDX-FileCopyrightText: 2018 Roeland Jago Douma +// SPDX-FileCopyrightText: 2022 Solution Libre SAS +// SPDX-FileContributor: Florent Poinsaut +// SPDX-License-Identifier: AGPL-3.0-or-later namespace OCA\ShareListing\Command; diff --git a/lib/Service/ReportSender.php b/lib/Service/ReportSender.php index 0945b2a4..a15a3f5a 100644 --- a/lib/Service/ReportSender.php +++ b/lib/Service/ReportSender.php @@ -1,29 +1,10 @@ - * - * @author Florent Poinsaut - * @author Robin Appelman - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ + +// SPDX-FileCopyrightText: 2020 Robin Appelman +// SPDX-FileCopyrightText: 2022 Solution Libre SAS +// SPDX-License-Identifier: AGPL-3.0-or-later namespace OCA\ShareListing\Service; @@ -54,50 +35,21 @@ class ReportSender { protected const REPORT_NAME = ' - Shares report.'; - /** @var string */ - private $appName; - /** @var Iconfig */ - private $config; - /** @var ?array */ - protected $diffReport = null; - - private $mailer; - private $userManager; - private $defaults; - private $l10nFactory; - private $logger; - - /** @var array */ - protected $reports = []; - /** @var SharesList */ - private $sharesList; - /** @var IRootFolder */ - private $root; - /** @var IURLGenerator */ - protected $url; + protected ?array $diffReport = null; + protected array $reports = []; public function __construct( - string $appName, - IConfig $config, - IMailer $mailer, - IUserManager $userManager, - Defaults $defaults, - IFactory $l10nFactory, - LoggerInterface $logger, - SharesList $sharesList, - IRootFolder $root, - IURLGenerator $url, + private readonly string $appName, + private readonly IConfig $config, + private readonly IMailer $mailer, + private readonly IUserManager $userManager, + private readonly Defaults $defaults, + private readonly IFactory $l10nFactory, + private readonly LoggerInterface $logger, + private readonly SharesList $sharesList, + private readonly IRootFolder $root, + private readonly IURLGenerator $url, ) { - $this->appName = $appName; - $this->config = $config; - $this->mailer = $mailer; - $this->userManager = $userManager; - $this->defaults = $defaults; - $this->l10nFactory = $l10nFactory; - $this->logger = $logger; - $this->sharesList = $sharesList; - $this->root = $root; - $this->url = $url; } public function createReport( @@ -108,7 +60,7 @@ public function createReport( int $filter = SharesList::FILTER_NONE, ?string $path = null, ?string $token = null, - ) { + ): void { $userFolder = $this->root->getUserFolder($recipient); if ($userFolder->nodeExists($targetPath)) { @@ -146,7 +98,7 @@ public function createReport( } } - public function sendReport(string $recipient, \DateTimeImmutable $dateTime) { + public function sendReport(string $recipient, \DateTimeImmutable $dateTime): void { $defaultLanguage = $this->config->getSystemValue('default_language', 'en'); $userLanguages = $this->config->getUserValue($recipient, 'core', 'lang'); $language = (!empty($userLanguages)) ? $userLanguages : $defaultLanguage; @@ -199,7 +151,6 @@ public function sendReport(string $recipient, \DateTimeImmutable $dateTime) { $this->mailer->send($message); } catch (\Exception $e) { $this->logger->error($e->getMessage()); - return; } } @@ -228,7 +179,7 @@ protected function getEmailAdressFromUserId(string $userId): ?string { public function diff( string $userId, string $dir, - ) { + ): void { $userFolder = $this->root->getUserFolder($userId); if ($userFolder->nodeExists($dir)) { From 79f387a1a97fc850c48c9938c70001138921e553 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 27 Oct 2025 14:17:32 +0100 Subject: [PATCH 4/4] perf(ReportSender): Don't use nodeExist/get Directly get and catch the exception, save one SQL query Signed-off-by: Carl Schwan --- lib/Service/ReportSender.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/Service/ReportSender.php b/lib/Service/ReportSender.php index a15a3f5a..d827a346 100644 --- a/lib/Service/ReportSender.php +++ b/lib/Service/ReportSender.php @@ -8,7 +8,6 @@ namespace OCA\ShareListing\Service; -use iter; use OC\Files\Search\SearchBinaryOperator; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchOrder; @@ -20,6 +19,7 @@ use OCP\Files\InvalidDirectoryException; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchComparison; use OCP\Files\Search\ISearchOrder; @@ -63,16 +63,13 @@ public function createReport( ): void { $userFolder = $this->root->getUserFolder($recipient); - if ($userFolder->nodeExists($targetPath)) { + try { /** @var Folder $folder */ $folder = $userFolder->get($targetPath); if ($folder->getType() !== FileInfo::TYPE_FOLDER) { - $this->logger->warning( - 'Target path ' . $targetPath . ' is not a folder', - ['app' => $this->appName] - ); + throw new \RuntimeException('Target path ' . $targetPath . ' is not a folder'); } - } else { + } catch (NotFoundException|NotPermittedException $e) { $folder = $userFolder->newFolder($targetPath); } @@ -82,7 +79,7 @@ public function createReport( $fileName = $formatedDateTime . self::REPORT_NAME . $format; if (!array_key_exists($fileName, $this->reports)) { if ($key === array_key_first($formats)) { - $shares = iter\toArray($this->sharesList->getFormattedShares($userId, $filter, $path, $token)); + $shares = iterator_to_array($this->sharesList->getFormattedShares($userId, $filter, $path, $token)); } $reportFile = $folder->newFile($fileName); $data = $this->sharesList->getSerializedShares($shares, $format);